Files
Checkmate/client/src/Components/inputs/forms/FormMultiSelectField.tsx
T

183 lines
5.5 KiB
TypeScript
Raw Normal View History

2026-08-07 11:50:47 -07:00
import { useMemo } from "react";
import {
Controller,
useFormContext,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Divider from "@mui/material/Divider";
import { useTheme } from "@mui/material/styles";
2026-08-06 14:40:21 -07:00
import { Trash2, GripVertical } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type { DropResult } from "@hello-pangea/dnd";
import { useTranslation } from "react-i18next";
import { Autocomplete } from "@/Components/inputs";
2026-08-06 14:40:21 -07:00
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
export interface MultiSelectOption {
id: string | number;
name: string;
}
interface FormMultiSelectFieldProps<T extends FieldValues, O extends MultiSelectOption> {
name: FieldPath<T>;
options: O[];
fieldLabel?: string;
2026-08-07 11:50:47 -07:00
placeholder?: string;
2026-08-05 11:51:20 -07:00
description?: string;
renderRow?: (option: O) => React.ReactNode;
renderOptionContent?: (option: O) => React.ReactNode;
2026-08-06 14:40:21 -07:00
sortable?: boolean;
}
export const FormMultiSelectField = <T extends FieldValues, O extends MultiSelectOption>({
name,
options,
fieldLabel,
2026-08-07 11:50:47 -07:00
placeholder,
2026-08-05 11:51:20 -07:00
description,
renderRow,
renderOptionContent,
2026-08-06 14:40:21 -07:00
sortable = false,
}: FormMultiSelectFieldProps<T, O>) => {
const { control } = useFormContext<T>();
const theme = useTheme();
const { t } = useTranslation();
2026-08-07 11:50:47 -07:00
const optionsById = useMemo(
() => new Map<O["id"], O>(options.map((o) => [o.id, o])),
[options]
);
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => {
2026-08-06 14:40:21 -07:00
const selectedIds: O["id"][] = field.value ?? [];
const selected = selectedIds
2026-08-07 11:50:47 -07:00
.map((id) => optionsById.get(id))
2026-08-06 14:40:21 -07:00
.filter((o): o is O => o !== undefined);
2026-08-07 11:50:47 -07:00
// Drag indexes refer to the rendered (resolved) list, not the raw id
// array — ids missing from options are filtered out above, so reorder
// the rendered ids and re-append unresolved ids at the end.
2026-08-06 14:40:21 -07:00
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
2026-08-07 11:50:47 -07:00
const reordered = selected.map((o) => o.id);
const [moved] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, moved);
const unresolved = selectedIds.filter((id) => !optionsById.has(id));
field.onChange([...reordered, ...unresolved]);
2026-08-06 14:40:21 -07:00
};
const rowContent = (option: O) =>
renderRow ? (
renderRow(option)
) : (
<Typography flexGrow={1}>{option.name}</Typography>
);
const removeButton = (option: O) => (
<IconButton
size="small"
onClick={() => {
field.onChange(selectedIds.filter((id) => id !== option.id));
}}
2026-08-07 11:50:47 -07:00
aria-label={t("common.buttons.removeItem", { item: option.name })}
2026-08-06 14:40:21 -07:00
>
<Trash2 size={16} />
</IconButton>
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
2026-08-05 11:51:20 -07:00
autoHighlight
options={options}
value={selected}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
onChange={(_: unknown, newValue: O[]) => {
field.onChange(newValue.map((o) => o.id));
}}
fieldLabel={fieldLabel}
2026-08-07 11:50:47 -07:00
placeholder={placeholder}
renderOptionContent={renderOptionContent}
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
2026-08-05 11:51:20 -07:00
{description && (
<Typography
component="span"
color={theme.palette.text.secondary}
sx={{ opacity: 0.8 }}
>
{description}
</Typography>
)}
2026-08-06 14:40:21 -07:00
{sortable
? selected.length > 0 && (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId={`${name}-list`}>
{(provided) => (
<Stack
{...provided.droppableProps}
ref={provided.innerRef}
>
{selected.map((option, index) => (
<Draggable
key={option.id}
draggableId={String(option.id)}
index={index}
>
{(provided) => (
<Stack
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
border={`1px solid ${theme.palette.divider}`}
sx={{
cursor: "grab",
"&:active": { cursor: "grabbing" },
}}
>
<GripVertical size={20} />
{rowContent(option)}
{removeButton(option)}
</Stack>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
)}
</Droppable>
</DragDropContext>
)
: selected.map((option, index) => (
<Stack
direction="row"
alignItems="center"
key={option.id}
width="100%"
>
{rowContent(option)}
{removeButton(option)}
{index < selected.length - 1 && <Divider />}
</Stack>
))}
</Stack>
);
}}
/>
);
};