// * 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, "animationConfig">, VariantProps { /** * 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 | 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( ( { 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(defaultValues); const [selectedModels, setSelectedModels] = useState( [], ); 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 | null>(null); const localErrors = parentErrors ?? errors; // Pagination. const [page, setPage] = useState(1); const [pageSize] = useState(5); const [lastPage, setLastPage] = useState(1); // Search identification. const requestIdRef = useRef(0); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [options, setOptions] = useState([]); const [optionsMerged, setOptionsMerged] = useState([]); // Custom by field parameter or predefined values (title, name or label). const [dynamicField, setDynamicField] = useState( 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(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(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(); 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) => { 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 (
{politeMessage}
{assertiveMessage}
Multi-select dropdown. Use arrow keys to navigate, Enter to select, and Escape to close.
{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(", ")}`}
)}
))} setIsPopoverOpen(false)} > {searchable && (
setSearchValue(e.target.value) } onKeyDown={handleInputKeyDown} className="w-[calc(100%-2.5rem-40px)] border-0 mx-9" /> } tooltipContent={

{str(transDelete) .ucFirst() .value()}

} 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 } />
)} {searchable && (
Type to filter options. Use arrow keys to navigate results.
)} {!loading && !initialLoading && ( {emptyIndicator || transNoResults} )} {/* First option for select all items */} {!hideSelectAll && !searchValue && multiple && ( ( {str(transSelectAll) .ucFirst() .value()} {getAllOptions().length > 20 ? ` - ${getAllOptions().length} options` : ""} ) )} {/* List of items */} {loading ? [...Array(pageSize)].map(() => ( )) : options.map((option) => { const isSelected = selectedValues.includes( option.id, ); return ( { 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", )} > {getModelField( option, )} ); })} {/* Pagination boutons */} {options.length > 0 && lastPage !== 1 && (
{GetTranslation( "pagination.previous", )} } tooltipContent={

{GetTranslation( "pagination.specific_page", { params: { id: String( page - 1, ), }, }, )}

} type="button" variant="ghost" size="sm" onClick={() => goToPage(page - 1)} disabled={page <= 1 || loading} /> {loading ? ( ) : (

{page}/{lastPage}

)} {GetTranslation( "pagination.next", )} } tooltipContent={

{GetTranslation( "pagination.specific_page", { params: { id: String( page + 1, ), }, }, )}

} type="button" variant="ghost" size="sm" onClick={() => goToPage(page + 1)} disabled={page >= lastPage || loading} />
)} {(closeButtonVisible || selectedValues.length > 0) && (
{selectedValues.length > 0 && ( {str(transClearList) .ucFirst() .value()}

} className="w-full" type="button" variant="ghost" size="sm" onClick={handleClear} disabled={disabled} /> )} {closeButtonVisible && selectedValues.length > 0 && ( )} {closeButtonVisible && ( {str(transClosePopover) .ucFirst() .value()}

} className="w-full" type="button" variant="ghost" size="sm" onClick={() => setIsPopoverOpen(false) } disabled={disabled} /> )}
)}
{animation > 0 && selectedValues.length > 0 && ( setIsAnimating(!isAnimating)} /> )} {helper && ( {helper} )} ); }, ); ModelSelect.displayName = "ModelSelect"; export type { ModelSelectProps };