ldc-groupe/resources/ts/back/Components/Inputs/ModelSelect.tsx
2026-03-06 16:04:15 +01:00

1825 lines
80 KiB
TypeScript

// * Packages
import { usePage } from "@inertiajs/react";
import {
ButtonHTMLAttributes,
CSSProperties,
forwardRef,
KeyboardEvent,
ReactNode,
useCallback,
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
} from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
CheckIcon,
XCircleIcon,
XIcon,
WandSparklesIcon,
SearchIcon,
ChevronsUpDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import axios, { isAxiosError } from "axios";
import { route } from "ziggy-js";
// * Components shadcn
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
// * Other
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
import InputError from "@/back/Components/InputError";
import str from "@/hooks/use-string";
import { cn, GetTranslation } from "@/lib/utils";
import { AppEnv, DefaultModel, Paginator } from "@/back/types/global";
import { TranslationField } from "@/back/types/i18n";
/**
* Animation types and configurations
*/
export interface AnimationConfig {
/** Badge animation type */
badgeAnimation?: "bounce" | "pulse" | "wiggle" | "fade" | "slide" | "none";
/** Popover animation type */
popoverAnimation?: "scale" | "slide" | "fade" | "flip" | "none";
/** Option hover animation type */
optionHoverAnimation?: "highlight" | "scale" | "glow" | "none";
/** Animation duration in seconds */
duration?: number;
/** Animation delay in seconds */
delay?: number;
}
/**
* Variants for the multi-select component to handle different styles.
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
*/
const modelSelectVariants = cva("transition-all duration-300 ease-in-out", {
variants: {
variant: {
default:
"border-foreground/10 text-foreground bg-card hover:bg-card/80",
ghost: "border-none text-foreground bg-background",
secondary:
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
inverted: "inverted",
},
badgeAnimation: {
bounce: "hover:-translate-y-1 hover:scale-110",
pulse: "hover:animate-pulse",
wiggle: "hover:animate-wiggle",
fade: "hover:opacity-80",
slide: "hover:translate-x-1",
none: "",
},
},
defaultVariants: {
variant: "default",
badgeAnimation: "none",
},
});
/**
* Props for MultiSelect component
*/
interface ModelSelectProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "animationConfig">,
VariantProps<typeof modelSelectVariants> {
/**
* Callback function triggered when the selected values change.
* Receives an array of the new selected values.
*/
// eslint-disable-next-line no-unused-vars
onValueChange?: (value: number[]) => void;
/**
* Callback function triggered when the selected values change.
* Receives a single or array of selected Model(s).
*/
// eslint-disable-next-line no-unused-vars
onModelChange?: (value: DefaultModel[]) => void;
/** The default selected values when the component mounts. */
defaultValues?: number[];
/**
* Placeholder text to be displayed when no values are selected.
* Optional, defaults to "Select options".
*/
placeholder?: string;
/**
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
* Optional, defaults to 0 (no animation).
*/
animation?: number;
/**
* Advanced animation configuration for different component parts.
* Optional, allows fine-tuning of various animation effects.
*/
animationConfig?: AnimationConfig;
/**
* Maximum number of items to display. Extra selected items will be summarized.
* Optional, defaults to 3.
*/
maxCount?: number;
/**
* The modality of the popover. When set to true, interaction with outside elements
* will be disabled and only popover content will be visible to screen readers.
* Optional, defaults to false.
*/
modalPopover?: boolean;
/**
* If true, renders the multi-select component as a child of another component.
* Optional, defaults to false.
*/
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
asChild?: boolean;
/**
* Additional class names to apply custom styles to the multi-select component.
* Optional, can be used to add custom styles.
*/
className?: string;
/**
* If true, disables the select all functionality.
* Optional, defaults to false.
*/
hideSelectAll?: boolean;
/**
* If true, shows search functionality in the popover.
* If false, hides the search input completely.
* Optional, defaults to true.
*/
searchable?: boolean;
/**
* Custom empty state message when no options match search.
* Optional, defaults to "No results found."
*/
emptyIndicator?: ReactNode;
/**
* If true, allows the component to grow and shrink with its content.
* If false, uses fixed width behavior.
* Optional, defaults to false.
*/
autoSize?: boolean;
/**
* If true, shows badges in a single line with horizontal scroll.
* If false, badges wrap to multiple lines.
* Optional, defaults to false.
*/
singleLine?: boolean;
/**
* Custom CSS class for the popover content.
* Optional, can be used to customize popover appearance.
*/
popoverClassName?: string;
/**
* If true, disables the component completely.
* Optional, defaults to false.
*/
disabled?: boolean;
/**
* Responsive configuration for different screen sizes.
* Allows customizing maxCount and other properties based on viewport.
* Can be boolean true for default responsive behavior or an object for custom configuration.
*/
responsive?:
| boolean
| {
/** Configuration for mobile devices (< 640px) */
mobile?: {
maxCount?: number;
hideIcons?: boolean;
compactMode?: boolean;
};
/** Configuration for tablet devices (640px - 1024px) */
tablet?: {
maxCount?: number;
hideIcons?: boolean;
compactMode?: boolean;
};
/** Configuration for desktop devices (> 1024px) */
desktop?: {
maxCount?: number;
hideIcons?: boolean;
compactMode?: boolean;
};
};
/**
* Minimum width for the component.
* Optional, defaults to auto-sizing based on content.
* When set, component will not shrink below this width.
*/
minWidth?: string;
/**
* Maximum width for the component.
* Optional, defaults to 100% of container.
* Component will not exceed container boundaries.
*/
maxWidth?: string;
/**
* If true, automatically removes duplicate options based on their value.
* Optional, defaults to false (shows warning in dev mode instead).
*/
deduplicateOptions?: boolean;
/**
* If true, the component will reset its internal state when defaultValue changes.
* Useful for React Hook Form integration and form reset functionality.
* Optional, defaults to true.
*/
resetOnDefaultValueChange?: boolean;
/**
* If true, automatically closes the popover after selecting an option.
* Useful for single-selection-like behavior or mobile UX.
* Optional, defaults to false.
*/
closeOnSelect?: boolean;
/**
* The name of the field to complete
*/
name: string;
/**
* Label of the field
*/
label: string;
/**
* Does the field required
*/
required?: boolean;
/**
* The fonction to set the data in useForm inertia
*/
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: number[] | number | null) => void;
/**
* Does the field is multiple ? If yes return will be array, if not it will be single value
*/
multiple?: boolean;
/**
* Minimum number of selections required (only when multiple is true)
* Optional, defaults to undefined (no minimum)
*/
minSelection?: number;
/**
* Maximum number of selections allowed (only when multiple is true)
* Optional, defaults to undefined (no maximum)
*/
maxSelection?: number;
/**
* Helper for the input
* Optional, defaults to empty
*/
helper?: string;
/**
* Make the Close button visible
* Optional, defaults to empty
*/
closeButtonVisible?: boolean;
/**
* Model name
* Take the name of the model to fetch in the backend, using ajax function
*/
targetModel?: string;
/**
* Field name used to find data in the option of the models(optional)
*/
field?: string;
/** Exclude id in the model options (optionnal) */
exclude?: number | null;
parentErrors?: Record<string, string> | null;
}
/**
* Imperative methods exposed through ref
*/
export interface ModelSelectRef {
/**
* Programmatically reset the component to its default value
*/
reset: () => void;
/**
* Get current selected values
*/
getSelectedValues: () => number[];
/**
* Set selected values programmatically
*/
// eslint-disable-next-line no-unused-vars
setSelectedValues: (values: number[]) => void;
/**
* Clear all selected values
*/
clear: () => void;
/**
* Focus the component
*/
focus: () => void;
}
export const ModelSelect = forwardRef<ModelSelectRef, ModelSelectProps>(
(
{
name,
label,
required = true,
setData,
multiple = true,
minSelection,
maxSelection,
helper,
onValueChange,
onModelChange,
variant,
defaultValues = [],
placeholder = "Select options",
animation = 0,
animationConfig,
maxCount = 3,
modalPopover = false,
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
asChild = false,
className,
hideSelectAll = false,
searchable = true,
emptyIndicator,
autoSize = false,
singleLine = false,
popoverClassName,
disabled = false,
responsive,
minWidth,
maxWidth,
deduplicateOptions = false,
resetOnDefaultValueChange = true,
closeOnSelect = false,
closeButtonVisible = false,
targetModel,
field,
exclude = null,
parentErrors = null,
...props
},
ref,
) => {
const [selectedValues, setSelectedValues] =
useState<number[]>(defaultValues);
const [selectedModels, setSelectedModels] = useState<DefaultModel[]>(
[],
);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [politeMessage, setPoliteMessage] = useState("");
const [assertiveMessage, setAssertiveMessage] = useState("");
const prevSelectedCount = useRef(selectedValues.length);
const prevIsOpen = useRef(isPopoverOpen);
const prevSearchValue = useRef(searchValue);
const { errors } = usePage().props;
const [errorAjax, setErrorsAjax] = useState<Record<
string,
string
> | null>(null);
const localErrors = parentErrors ?? errors;
// Pagination.
const [page, setPage] = useState<number>(1);
const [pageSize] = useState<number>(5);
const [lastPage, setLastPage] = useState<number>(1);
// Search identification.
const requestIdRef = useRef(0);
const [loading, setLoading] = useState<boolean>(false);
const [initialLoading, setInitialLoading] = useState<boolean>(true);
const [options, setOptions] = useState<DefaultModel[]>([]);
const [optionsMerged, setOptionsMerged] = useState<DefaultModel[]>([]);
// Custom by field parameter or predefined values (title, name or label).
const [dynamicField, setDynamicField] = useState<string | null>(
field ?? null,
);
const { locale } = usePage().props.appEnv as AppEnv;
/**
* Set call ajax.
* @param pageNumber number
* @return
*/
const fetchOptions = async (pageNumber: number) => {
const csrfToken =
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content") || "";
const response = await axios.post(
route("back.model.list"),
{
model: targetModel,
selectedIds: selectedValues,
search: searchValue,
field: dynamicField,
page: pageNumber,
pageSize,
exclude,
},
{
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-Inertia": "false",
"X-CSRF-TOKEN": csrfToken,
},
},
);
return response.data;
};
/**
* Get new options list with his target field.
* @param pageNumber number
* @return void
*/
const getData = async (pageNumber = 1) => {
// Increment search id.
requestIdRef.current += 1;
const requestId = requestIdRef.current;
setLoading(true);
try {
const data = (await fetchOptions(pageNumber)) as {
items: Paginator;
field: string;
model: DefaultModel[];
};
// Ignore obsolete answers.
if (requestId !== requestIdRef.current) return;
const dataModel: DefaultModel[] = data.model
? Object.values(data.model)
: [];
const dataItems: DefaultModel[] = Array.isArray(
data?.items?.data,
)
? data.items.data
: [];
setOptionsMerged(() => {
const merged = [...dataModel, ...dataItems]
// Supprime les éléments undefined ou sans id
.filter(
(item): item is DefaultModel =>
!!item && item.id !== undefined,
)
// Supprime les doublons par id
.filter(
(item, index, self) =>
index ===
self.findIndex((i) => i.id === item.id),
);
return merged;
});
setSelectedModels(dataModel);
setDynamicField(data.field);
setOptions(data.items.data ?? []);
setPage(data.items.current_page);
setLastPage(data.items.last_page ?? 1);
} catch (err: unknown) {
// Ignore obsolete errors.
if (requestId !== requestIdRef.current) return;
console.error(err);
if (isAxiosError(err) && err.response?.data?.errors) {
setErrorsAjax(err.response?.data?.errors);
}
setOptions([]);
setLastPage(1);
} finally {
// Only the last call can stop the loading.
if (requestId === requestIdRef.current) {
setLoading(false);
}
setInitialLoading(false);
}
};
/**
* Get specific page.
* @param pageNumber number
* @return void
*/
const goToPage = async (pageNumber: number) => {
if (pageNumber < 1 || pageNumber > lastPage) return;
await getData(pageNumber);
};
useEffect(() => {
if (disabled) {
return undefined;
}
// Reset options list if empty search or null
if (targetModel && searchValue.length === 0) {
getData();
return undefined;
}
// Nothing to do if search is below 3 caracters.
if (searchValue.length < 3) return undefined;
// Get updated options list with search.
const handler = setTimeout(() => getData(), 300);
function cleanup(): void {
if (handler) {
clearTimeout(handler);
}
}
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchValue, targetModel]);
const announce = useCallback(
(message: string, priority: "polite" | "assertive" = "polite") => {
if (priority === "assertive") {
setAssertiveMessage(message);
setTimeout(() => setAssertiveMessage(""), 100);
} else {
setPoliteMessage(message);
setTimeout(() => setPoliteMessage(""), 100);
}
},
[],
);
const multiSelectId = useId();
const listboxId = `${multiSelectId}-listbox`;
const triggerDescriptionId = `${multiSelectId}-description`;
const selectedCountId = `${multiSelectId}-count`;
// Translations.
const transDelete = GetTranslation("crud.actions.delete");
const transClose = GetTranslation("back.close");
const transClear = GetTranslation("back.clear");
const transNoResults = GetTranslation("back.table_no_results");
const transSearchOptions = GetTranslation(
"back.multi_select.search_options",
);
const transSelectAll = GetTranslation("back.multi_select.select_all");
const transMore = GetTranslation("back.multi_select.more");
const transClosePopover = GetTranslation(
"back.multi_select.close_popover",
);
const transClearList = GetTranslation("back.multi_select.clear_list");
const prevDefaultValueRef = useRef<number[]>(defaultValues);
const arraysEqual = useCallback((a: number[], b: number[]): boolean => {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((val, index) => val === sortedB[index]);
}, []);
const resetToDefault = useCallback(() => {
setSelectedValues(defaultValues);
setIsPopoverOpen(false);
setSearchValue("");
if (onValueChange) {
onValueChange(defaultValues);
}
setData(name, multiple ? defaultValues : defaultValues[0]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues, onValueChange]);
const buttonRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(
ref,
() => ({
reset: resetToDefault,
getSelectedValues: () => selectedValues,
setSelectedValues: (values: number[]) => {
setSelectedValues(values);
if (onValueChange) {
onValueChange(values);
}
setData(name, multiple ? values : values[0]);
},
clear: () => {
setSelectedValues([]);
if (onValueChange) {
onValueChange([]);
}
setData(name, multiple ? [] : null);
},
focus: () => {
if (buttonRef.current) {
buttonRef.current.focus();
const originalOutline = buttonRef.current.style.outline;
const originalOutlineOffset =
buttonRef.current.style.outlineOffset;
buttonRef.current.style.outline =
"2px solid hsl(var(--ring))";
buttonRef.current.style.outlineOffset = "2px";
setTimeout(() => {
if (buttonRef.current) {
buttonRef.current.style.outline =
originalOutline;
buttonRef.current.style.outlineOffset =
originalOutlineOffset;
}
}, 1000);
}
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[resetToDefault, selectedValues, onValueChange],
);
const [screenSize, setScreenSize] = useState<
"mobile" | "tablet" | "desktop"
>("desktop");
useEffect(() => {
if (typeof window === "undefined") return undefined;
const handleResize = () => {
const width = window.innerWidth;
if (width < 640) {
setScreenSize("mobile");
} else if (width < 1024) {
setScreenSize("tablet");
} else {
setScreenSize("desktop");
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => {
if (typeof window !== "undefined") {
window.removeEventListener("resize", handleResize);
}
};
}, []);
const getResponsiveSettings = () => {
if (!responsive) {
return {
maxCount,
hideIcons: false,
compactMode: false,
};
}
if (responsive === true) {
const defaultResponsive = {
mobile: {
maxCount: 2,
hideIcons: false,
compactMode: true,
},
tablet: {
maxCount: 4,
hideIcons: false,
compactMode: false,
},
desktop: {
maxCount: 6,
hideIcons: false,
compactMode: false,
},
};
const currentSettings = defaultResponsive[screenSize];
return {
maxCount: currentSettings?.maxCount ?? maxCount,
hideIcons: currentSettings?.hideIcons ?? false,
compactMode: currentSettings?.compactMode ?? false,
};
}
const currentSettings = responsive[screenSize];
return {
maxCount: currentSettings?.maxCount ?? maxCount,
hideIcons: currentSettings?.hideIcons ?? false,
compactMode: currentSettings?.compactMode ?? false,
};
};
const responsiveSettings = getResponsiveSettings();
const getBadgeAnimationClass = () => {
if (animationConfig?.badgeAnimation) {
switch (animationConfig.badgeAnimation) {
case "bounce":
return isAnimating
? "animate-bounce"
: "hover:-translate-y-1 hover:scale-110";
case "pulse":
return "hover:animate-pulse";
case "wiggle":
return "hover:animate-wiggle";
case "fade":
return "hover:opacity-80";
case "slide":
return "hover:translate-x-1";
case "none":
return "";
default:
return "";
}
}
return isAnimating ? "animate-bounce" : "";
};
const getPopoverAnimationClass = () => {
if (animationConfig?.popoverAnimation) {
switch (animationConfig.popoverAnimation) {
case "scale":
return "animate-scaleIn";
case "slide":
return "animate-slideInDown";
case "fade":
return "animate-fadeIn";
case "flip":
return "animate-flipIn";
case "none":
return "";
default:
return "";
}
}
return "";
};
const getAllOptions = useCallback((): DefaultModel[] => {
if (optionsMerged.length === 0) return [];
const allOptions: DefaultModel[] = optionsMerged;
const valueSet = new Set<string>();
const duplicates: string[] = [];
const uniqueOptions: DefaultModel[] = [];
allOptions.forEach((option) => {
if (valueSet.has(option.id.toString())) {
duplicates.push(option.id.toString());
if (!deduplicateOptions) {
uniqueOptions.push(option);
}
} else {
valueSet.add(option.id.toString());
uniqueOptions.push(option);
}
});
if (
process.env.NODE_ENV === "development" &&
duplicates.length > 0
) {
const action = deduplicateOptions
? "automatically removed"
: "detected";
console.warn(
`MultiSelect: Duplicate option values ${action}: ${duplicates.join(
", ",
)}. ` +
`${
deduplicateOptions
? "Duplicates have been removed automatically."
: "This may cause unexpected behavior. Consider setting 'deduplicateOptions={true}' or ensure all option values are unique."
}`,
);
}
return deduplicateOptions ? uniqueOptions : allOptions;
}, [optionsMerged, deduplicateOptions]);
const getOptionByValue = useCallback(
(id: number): DefaultModel | undefined => {
const option = getAllOptions().find((opt) => opt.id === id);
if (!option && process.env.NODE_ENV === "development") {
console.warn(
`MultiSelect: Option with value "${id}" not found in options list`,
);
}
return option;
},
[getAllOptions],
);
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
setIsPopoverOpen(true);
} else if (
event.key === "Backspace" &&
!event.currentTarget.value
) {
const newSelectedValues = [...selectedValues];
const newSelectedModels = [...selectedModels];
newSelectedValues.pop();
newSelectedModels.pop();
setSelectedValues(newSelectedValues);
setSelectedModels(newSelectedModels);
if (onValueChange) {
onValueChange(newSelectedValues);
}
if (onModelChange) {
onModelChange(newSelectedModels);
}
setData(
name,
multiple ? newSelectedValues : newSelectedValues[0],
);
}
};
const toggleOption = (option: DefaultModel) => {
if (disabled) return;
// MODE SINGLE SELECT
if (!multiple) {
const newSelectedValues = selectedValues.includes(option.id)
? []
: [option.id];
setSelectedValues(newSelectedValues);
if (onValueChange) {
onValueChange(newSelectedValues);
}
const newSelectedModels = selectedModels?.includes(option)
? []
: [option];
setSelectedModels(newSelectedModels);
if (onModelChange) {
onModelChange(newSelectedModels);
}
setData(
name,
multiple ? newSelectedValues : newSelectedValues[0],
);
if (closeOnSelect) {
setIsPopoverOpen(false);
}
return;
}
// MODE MULTIPLE SELECT
const isCurrentlySelected = selectedValues.includes(option.id);
if (isCurrentlySelected) {
if (minSelection && selectedValues.length <= minSelection) {
announce(
`Minimum ${minSelection} selection(s) required`,
"assertive",
);
return;
}
const newSelectedValues = selectedValues.filter(
(value) => value !== option.id,
);
setSelectedValues(newSelectedValues);
if (onValueChange) {
onValueChange(newSelectedValues);
}
const newSelectedModels = selectedModels?.filter(
(value) => value.id !== option.id,
);
if (onModelChange) {
onModelChange(newSelectedModels ?? []);
}
setSelectedModels(newSelectedModels);
setData(
name,
multiple ? newSelectedValues : newSelectedValues[0],
);
} else {
if (maxSelection && selectedValues.length >= maxSelection) {
announce(
`Maximum ${maxSelection} selection(s) allowed`,
"assertive",
);
return;
}
const newSelectedValues = [...selectedValues, option.id];
setSelectedValues(newSelectedValues);
if (onValueChange) {
onValueChange(newSelectedValues);
}
const newSelectedModels = [...(selectedModels || []), option];
if (onModelChange) {
onModelChange(newSelectedModels);
}
setSelectedModels(newSelectedModels);
setData(
name,
multiple ? newSelectedValues : newSelectedValues[0],
);
}
if (closeOnSelect) {
setIsPopoverOpen(false);
}
};
const handleClear = () => {
if (disabled) return;
if (multiple && minSelection && minSelection > 0) {
announce(
`Minimum ${minSelection} selection(s) required`,
"assertive",
);
return;
}
setSelectedValues([]);
if (onValueChange) {
onValueChange([]);
}
setSelectedModels([]);
if (onModelChange) {
onModelChange([]);
}
setData(name, multiple ? [] : null);
};
const handleTogglePopover = () => {
if (disabled) return;
setIsPopoverOpen((prev) => !prev);
};
const clearExtraOptions = () => {
if (disabled) return;
const newSelectedValues = selectedValues.slice(
0,
responsiveSettings.maxCount,
);
setSelectedValues(newSelectedValues);
if (onValueChange) {
onValueChange(newSelectedValues);
}
const newSelectedModels = selectedModels.slice(
0,
responsiveSettings.maxCount,
);
setSelectedModels(newSelectedModels);
if (onModelChange) {
onModelChange(newSelectedModels);
}
setData(name, multiple ? newSelectedValues : newSelectedValues[0]);
};
const toggleAll = () => {
if (disabled || !multiple) return;
const allOptions = getAllOptions();
if (selectedValues.length === allOptions.length) {
if (minSelection && minSelection > 0) {
announce(
`Minimum ${minSelection} selection(s) required`,
"assertive",
);
return;
}
handleClear();
} else {
const allValues = allOptions.map((option) => option.id);
if (maxSelection && allValues.length > maxSelection) {
announce(
`Maximum ${maxSelection} selection(s) allowed`,
"assertive",
);
return;
}
setSelectedValues(allValues);
if (onValueChange) {
onValueChange(allValues);
}
setSelectedModels(allOptions);
if (onModelChange) {
onModelChange(allOptions);
}
setData(name, multiple ? allValues : allValues[0]);
}
if (closeOnSelect) {
setIsPopoverOpen(false);
}
};
useEffect(() => {
if (!resetOnDefaultValueChange) return;
const prevDefaultValue = prevDefaultValueRef.current;
if (!arraysEqual(prevDefaultValue, defaultValues)) {
if (!arraysEqual(selectedValues, defaultValues)) {
setSelectedValues(defaultValues);
}
prevDefaultValueRef.current = [...defaultValues];
}
}, [
defaultValues,
selectedValues,
arraysEqual,
resetOnDefaultValueChange,
]);
const getWidthConstraints = () => {
const defaultMinWidth = screenSize === "mobile" ? "0px" : "200px";
const effectiveMinWidth = minWidth || defaultMinWidth;
const effectiveMaxWidth = maxWidth || "100%";
return {
minWidth: effectiveMinWidth,
maxWidth: effectiveMaxWidth,
width: autoSize ? "auto" : "100%",
};
};
const widthConstraints = getWidthConstraints();
useEffect(() => {
if (!isPopoverOpen) {
setSearchValue("");
}
}, [isPopoverOpen]);
useEffect(
() => {
const selectedCount = selectedValues.length;
const allOptions = getAllOptions();
const totalOptions = allOptions.length;
if (selectedCount !== prevSelectedCount.current) {
const diff = selectedCount - prevSelectedCount.current;
if (diff > 0) {
const addedItems = selectedValues.slice(-diff);
const addedLabels = addedItems
.map(
(value) =>
allOptions.find(
(opt) => opt.id === value,
)?.[dynamicField as keyof DefaultModel],
)
.filter(Boolean);
if (addedLabels.length === 1) {
announce(
`${addedLabels[0]} selected. ${selectedCount} of ${totalOptions} options selected.`,
);
} else {
announce(
`${addedLabels.length} options selected. ${selectedCount} of ${totalOptions} total selected.`,
);
}
} else if (diff < 0) {
announce(
`Option removed. ${selectedCount} of ${totalOptions} options selected.`,
);
}
prevSelectedCount.current = selectedCount;
}
if (isPopoverOpen !== prevIsOpen.current) {
if (isPopoverOpen) {
announce(
`Dropdown opened. ${totalOptions} options available. Use arrow keys to navigate.`,
);
} else {
announce("Dropdown closed.");
}
prevIsOpen.current = isPopoverOpen;
}
if (
searchValue !== prevSearchValue.current &&
searchValue !== undefined
) {
if (searchValue && isPopoverOpen) {
const filteredCount = allOptions.length;
announce(
`${filteredCount} option${
filteredCount === 1 ? "" : "s"
} found for "${searchValue}"`,
);
}
prevSearchValue.current = searchValue;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
selectedValues,
isPopoverOpen,
searchValue,
announce,
getAllOptions,
],
);
useEffect(() => {
if (
minSelection !== undefined &&
maxSelection !== undefined &&
minSelection > maxSelection
) {
console.error(
"MultiSelect: minSelection cannot be greater than maxSelection",
);
}
if (
!multiple &&
(minSelection !== undefined || maxSelection !== undefined)
) {
console.warn(
"MultiSelect: minSelection and maxSelection are ignored when multiple is false",
);
}
if (minSelection !== undefined && minSelection < 0) {
console.error("MultiSelect: minSelection cannot be negative");
}
if (maxSelection !== undefined && maxSelection < 1) {
console.error("MultiSelect: maxSelection must be at least 1");
}
}, [minSelection, maxSelection, multiple]);
const getModelField = (option: DefaultModel): string => {
const value = option[
dynamicField as keyof DefaultModel
] as unknown as TranslationField | string;
if (typeof value === "object") return String(value[locale] ?? "");
return String(value ?? "");
};
const buttonProps = {
...props,
options: options
.map((option) => getModelField(option))
.filter(Boolean)
.join(", "),
};
return (
<div className="w-full flex flex-col w-full justify-start items-start gap-1.5">
<div className="sr-only">
<div aria-live="polite" aria-atomic="true" role="status">
{politeMessage}
</div>
<div aria-live="assertive" aria-atomic="true" role="alert">
{assertiveMessage}
</div>
</div>
<Label htmlFor={name}>{label + (required ? " *" : "")}</Label>
<Popover
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
modal={modalPopover}
>
<div id={triggerDescriptionId} className="sr-only">
Multi-select dropdown. Use arrow keys to navigate, Enter
to select, and Escape to close.
</div>
<div
id={selectedCountId}
className="sr-only"
aria-live="polite"
>
{selectedValues.length === 0
? "No options selected"
: `${selectedValues.length} option${
selectedValues.length === 1 ? "" : "s"
} selected: ${selectedValues
.map((value) => {
const option = getOptionByValue(value);
if (!option) return undefined;
return getModelField(option);
})
.filter(Boolean)
.join(", ")}`}
</div>
<PopoverTrigger asChild>
<Button
ref={buttonRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...buttonProps}
onClick={handleTogglePopover}
disabled={disabled}
role="combobox"
variant="outline"
aria-expanded={isPopoverOpen}
aria-haspopup="listbox"
aria-controls={
isPopoverOpen ? listboxId : undefined
}
aria-describedby={`${triggerDescriptionId} ${selectedCountId}`}
aria-label={`Multi-select: ${selectedValues.length} of ${
getAllOptions().length
} options selected. ${placeholder}`}
className={cn(
"group/button flex rounded-md border min-h-10 h-auto items-center justify-between [&_svg]:pointer-events-auto gap-2 px-4 py-2",
autoSize ? "w-auto" : "w-full",
responsiveSettings.compactMode &&
"min-h-8 text-sm",
screenSize === "mobile" && "min-h-12 text-base",
disabled && "opacity-50 cursor-not-allowed",
{ "border-destructive/50": localErrors[name] },
className,
)}
style={{
...widthConstraints,
maxWidth: `min(${widthConstraints.maxWidth}, 100%)`,
}}
>
{selectedValues.length === 0 && (
<div className="flex items-center justify-between w-full mx-auto gap-2">
<p className="text-start text-sm whitespace-pre-line">
{placeholder}
</p>
<ChevronsUpDownIcon className="h-4 cursor-pointer opacity-50 group-hover/button:opacity-60" />
</div>
)}
{selectedValues.length > 0 &&
(initialLoading ? (
<Skeleton className="w-64 h-3 rounded-full" />
) : (
<div className="flex justify-between items-stretch w-full gap-2">
<div
className={cn(
"flex items-center gap-1",
singleLine
? "overflow-x-auto multiselect-singleline-scroll"
: "flex-wrap",
responsiveSettings.compactMode &&
"gap-0.5",
)}
>
{selectedValues
.slice(
0,
responsiveSettings.maxCount,
)
.map((value) => {
const option =
getOptionByValue(value);
if (!option) {
return null;
}
const badgeStyle: CSSProperties =
{
animationDuration: `${animation}s`,
};
return multiple ? (
<Badge
key={value}
className={cn(
getBadgeAnimationClass(),
modelSelectVariants(
{
variant:
multiple
? variant
: "ghost",
},
),
responsiveSettings.compactMode &&
"text-xs px-1.5 py-0.5",
screenSize ===
"mobile" &&
"max-w-[120px] truncate",
singleLine &&
"flex-shrink-0 whitespace-nowrap",
"[&>svg]:pointer-events-auto",
)}
style={{
...badgeStyle,
animationDuration: `${
animationConfig?.duration ||
animation
}s`,
animationDelay: `${animationConfig?.delay || 0}s`,
}}
>
<span
className={cn(
screenSize ===
"mobile" &&
"truncate",
)}
>
{getModelField(
option,
)}
</span>
<div
role="button"
tabIndex={0}
onClick={(
event,
) => {
event.stopPropagation();
toggleOption(
option,
);
}}
onKeyDown={(
event,
) => {
if (
event.key ===
"Enter" ||
event.key ===
" "
) {
event.preventDefault();
event.stopPropagation();
toggleOption(
option,
);
}
}}
aria-label={`Remove ${option[dynamicField as keyof DefaultModel]} from selection`}
className="ml-2 h-4 w-4 cursor-pointer hover:bg-white/20 rounded-sm -m-0.5 focus:outline-none focus:ring-1 focus:ring-white/50"
>
<XCircleIcon
className={cn(
"h-3 w-3",
responsiveSettings.compactMode &&
"h-2.5 w-2.5",
)}
/>
</div>
</Badge>
) : (
<p
className={cn(
"flex items-center overflow-hidden whitespace-nowrap max-w-[180px]",
singleLine &&
"flex-shrink-0",
screenSize ===
"mobile" &&
"max-w-[120px]",
responsiveSettings.compactMode &&
"text-xs px-1.5 py-0.5",
)}
>
<span className="truncate">
{getModelField(
option,
)}
</span>
</p>
);
})
.filter(Boolean)}
{selectedValues.length >
responsiveSettings.maxCount && (
<Badge
className={cn(
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
getBadgeAnimationClass(),
modelSelectVariants({
variant,
}),
responsiveSettings.compactMode &&
"text-xs px-1.5 py-0.5",
singleLine &&
"flex-shrink-0 whitespace-nowrap",
"[&>svg]:pointer-events-auto",
)}
style={{
animationDuration: `${animationConfig?.duration || animation}s`,
animationDelay: `${animationConfig?.delay || 0}s`,
}}
>
{`+ ${
selectedValues.length -
responsiveSettings.maxCount
} ${transMore}`}
<XCircleIcon
className={cn(
"ml-2 h-4 w-4 cursor-pointer",
responsiveSettings.compactMode &&
"ml-1 h-3 w-3",
)}
onClick={(event) => {
event.stopPropagation();
clearExtraOptions();
}}
/>
</Badge>
)}
</div>
<div className="flex items-center justify-between">
{!required && (
<Button
role="button"
variant="ghost"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
onKeyDown={(event) => {
if (
event.key ===
"Enter" ||
event.key === " "
) {
event.preventDefault();
event.stopPropagation();
handleClear();
}
}}
aria-label={`Clear all ${selectedValues.length} selected options`}
className="flex items-center justify-center h-4 w-4 p-0 me-2 cursor-pointer transition-opacity opacity-50 group-hover:opacity-60 hover:opacity-100 rounded-sm"
>
<XIcon className="h-4 w-4" />
</Button>
)}
<Separator
orientation="vertical"
className="flex min-h-4 h-full"
/>
<ChevronsUpDownIcon
className="h-4 ms-2 cursor-pointer opacity-50 group-hover/button:opacity-60"
aria-hidden="true"
/>
</div>
</div>
))}
</Button>
</PopoverTrigger>
<PopoverContent
id={listboxId}
role="listbox"
aria-multiselectable="true"
aria-label="Available options"
className={cn(
"w-[var(--radix-popover-trigger-width)] p-0",
getPopoverAnimationClass(),
popoverClassName,
)}
style={{
animationDuration: `${animationConfig?.duration || animation}s`,
animationDelay: `${animationConfig?.delay || 0}s`,
maxWidth: `min(${widthConstraints.maxWidth}, 85vw)`,
maxHeight:
screenSize === "mobile" ? "70vh" : "60vh",
touchAction: "manipulation",
}}
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command className="overflow-visible">
{searchable && (
<div className="relative z-10">
<div className="absolute top-1/2 left-2 transform -translate-y-1/2 text-muted-foreground">
<SearchIcon className="w-4 h-4" />
</div>
<Input
type="text"
placeholder={str(transSearchOptions)
.ucFirst()
.value()}
value={searchValue}
disabled={disabled}
onChange={(e) =>
setSearchValue(e.target.value)
}
onKeyDown={handleInputKeyDown}
className="w-[calc(100%-2.5rem-40px)] border-0 mx-9"
/>
<BtnOptionnalLinkWithTooltip
content={<XIcon />}
tooltipContent={
<p>
{str(transDelete)
.ucFirst()
.value()}
</p>
}
type="button"
variant="ghost"
size="icon"
className="absolute top-1/2 right-0 transform -translate-y-1/2 text-muted-foreground"
onClick={() => setSearchValue("")}
disabled={
searchValue.length < 1 || disabled
}
/>
</div>
)}
{searchable && (
<div
id={`${multiSelectId}-search-help`}
className="sr-only"
>
Type to filter options. Use arrow keys to
navigate results.
</div>
)}
<CommandList className="border-t p-1">
{!loading && !initialLoading && (
<CommandEmpty>
{emptyIndicator || transNoResults}
</CommandEmpty>
)}
{/* First option for select all items */}
{!hideSelectAll && !searchValue && multiple && (
<CommandGroup className="p-0">
<CommandItem
key="all"
onSelect={toggleAll}
role="option"
aria-selected={
selectedValues.length ===
getAllOptions().length
}
aria-label={`Select all ${
getAllOptions().length
} options`}
className="cursor-pointer gap-1.5 px-4 py-2 w-full h-auto min-h-10"
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-sm !border !border-primary",
selectedValues.length ===
getAllOptions().length
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
aria-hidden="true"
>
<CheckIcon
className="h-4 w-4 text-white"
strokeWidth={4}
/>
</div>
<span>
(
{str(transSelectAll)
.ucFirst()
.value()}
{getAllOptions().length > 20
? ` - ${getAllOptions().length} options`
: ""}
)
</span>
</CommandItem>
</CommandGroup>
)}
{/* List of items */}
<CommandGroup className="p-0">
{loading
? [...Array(pageSize)].map(() => (
<Skeleton className="h-9 mb-1" />
))
: options.map((option) => {
const isSelected =
selectedValues.includes(
option.id,
);
return (
<CommandItem
key={option.id}
onSelect={() => {
toggleOption(option);
}}
role="option"
aria-selected={isSelected}
aria-label={`${getModelField(option)}${
isSelected
? ", selected"
: ", not selected"
}
`}
className={cn(
"cursor-pointer gap-1.5 px-4 py-2 w-full h-auto min-h-10",
)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-sm !border !border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
aria-hidden="true"
>
<CheckIcon
className="h-4 w-4 text-white"
strokeWidth={4}
/>
</div>
<span>
{getModelField(
option,
)}
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
{/* Pagination boutons */}
{options.length > 0 && lastPage !== 1 && (
<div className="border-t flex flex-col lg:flex-row justify-between items-center gap-1.5 p-1">
<BtnOptionnalLinkWithTooltip
content={
<>
<ChevronLeftIcon />
{GetTranslation(
"pagination.previous",
)}
</>
}
tooltipContent={
<p>
{GetTranslation(
"pagination.specific_page",
{
params: {
id: String(
page - 1,
),
},
},
)}
</p>
}
type="button"
variant="ghost"
size="sm"
onClick={() => goToPage(page - 1)}
disabled={page <= 1 || loading}
/>
{loading ? (
<Skeleton className="w-10 h-5" />
) : (
<div className="w-10">
<p className="text-sm text-center text-muted-foreground">
{page}/{lastPage}
</p>
</div>
)}
<BtnOptionnalLinkWithTooltip
content={
<>
{GetTranslation(
"pagination.next",
)}
<ChevronRightIcon />
</>
}
tooltipContent={
<p>
{GetTranslation(
"pagination.specific_page",
{
params: {
id: String(
page + 1,
),
},
},
)}
</p>
}
type="button"
variant="ghost"
size="sm"
onClick={() => goToPage(page + 1)}
disabled={page >= lastPage || loading}
/>
</div>
)}
{(closeButtonVisible ||
selectedValues.length > 0) && (
<CommandGroup className="border-t p-1">
<div className="flex items-center justify-between gap-1">
{selectedValues.length > 0 && (
<BtnOptionnalLinkWithTooltip
content={str(transClear)
.ucFirst()
.value()}
tooltipContent={
<p>
{str(transClearList)
.ucFirst()
.value()}
</p>
}
className="w-full"
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
disabled={disabled}
/>
)}
{closeButtonVisible &&
selectedValues.length > 0 && (
<Separator
orientation="vertical"
className="flex min-h-5 h-full"
/>
)}
{closeButtonVisible && (
<BtnOptionnalLinkWithTooltip
content={str(transClose)
.ucFirst()
.value()}
tooltipContent={
<p>
{str(transClosePopover)
.ucFirst()
.value()}
</p>
}
className="w-full"
type="button"
variant="ghost"
size="sm"
onClick={() =>
setIsPopoverOpen(false)
}
disabled={disabled}
/>
)}
</div>
</CommandGroup>
)}
</Command>
</PopoverContent>
{animation > 0 && selectedValues.length > 0 && (
<WandSparklesIcon
className={cn(
"cursor-pointer my-2 text-foreground bg-background w-3 h-3",
isAnimating ? "" : "text-muted-foreground",
)}
onClick={() => setIsAnimating(!isAnimating)}
/>
)}
</Popover>
{helper && (
<small className="text-muted-foreground">{helper}</small>
)}
<InputError
message={
errorAjax
? "une erreur est survenue"
: localErrors[name]
}
/>
</div>
);
},
);
ModelSelect.displayName = "ModelSelect";
export type { ModelSelectProps };