2026-03-30 14:17:31 +02:00
|
|
|
import { usePage } from "@inertiajs/react";
|
|
|
|
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
|
|
|
import {
|
|
|
|
|
CheckIcon,
|
|
|
|
|
ChevronLeftIcon,
|
|
|
|
|
ChevronRightIcon,
|
2026-10-19 16:24:15 +02:00
|
|
|
ChevronsUpDownIcon,
|
|
|
|
|
SearchIcon,
|
|
|
|
|
WandSparklesIcon,
|
|
|
|
|
XCircleIcon,
|
|
|
|
|
XIcon,
|
2026-03-30 14:17:31 +02:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import {
|
2026-10-16 12:07:08 +02:00
|
|
|
type ButtonHTMLAttributes,
|
|
|
|
|
type CSSProperties,
|
|
|
|
|
type KeyboardEvent,
|
|
|
|
|
type ReactNode,
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useId,
|
|
|
|
|
useImperativeHandle,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from "react";
|
|
|
|
|
|
|
|
|
|
import BtnOptionnalLinkWithTooltip from "@/back/Components/BtnOptionnalLinkWithTooltip";
|
|
|
|
|
import InputError from "@/back/Components/InputError";
|
|
|
|
|
import type {
|
2026-10-19 16:24:15 +02:00
|
|
|
ConfigBackend,
|
2026-10-16 12:07:08 +02:00
|
|
|
DefaultModel,
|
|
|
|
|
Paginator,
|
|
|
|
|
} from "@/back/types/global";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-03-30 14:17:31 +02:00
|
|
|
import {
|
|
|
|
|
Command,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandItem,
|
|
|
|
|
CommandList,
|
|
|
|
|
} from "@/components/ui/command";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
2026-10-16 12:07:08 +02:00
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2026-03-30 14:17:31 +02:00
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
|
import str from "@/hooks/use-string";
|
2026-10-19 17:43:33 +02:00
|
|
|
import trans from "@/hooks/use-translations";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-03-30 14:17:31 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-10-16 12:07:08 +02:00
|
|
|
|
2026-03-30 14:17:31 +02:00
|
|
|
onValueChange?: (value: number[]) => void;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Callback function triggered when the selected values change.
|
|
|
|
|
* Receives a single or array of selected Model(s).
|
|
|
|
|
*/
|
2026-10-16 12:07:08 +02:00
|
|
|
|
2026-03-30 14:17:31 +02:00
|
|
|
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.
|
|
|
|
|
*/
|
2026-10-16 12:07:08 +02:00
|
|
|
|
2026-03-30 14:17:31 +02:00
|
|
|
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: ReactNode | string;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Does the field required
|
|
|
|
|
*/
|
|
|
|
|
required?: boolean;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The fonction to set the data in useForm inertia
|
|
|
|
|
*/
|
2026-10-16 12:07:08 +02:00
|
|
|
|
2026-03-30 14:17:31 +02:00
|
|
|
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
|
2026-10-16 12:07:08 +02:00
|
|
|
* Optional, defaults to true
|
2026-03-30 14:17:31 +02:00
|
|
|
*/
|
|
|
|
|
closeButtonVisible?: boolean;
|
|
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
/**
|
|
|
|
|
* Make the Empty button visible
|
|
|
|
|
* Optional, defaults to true
|
|
|
|
|
*/
|
|
|
|
|
emptyButtonVisible?: boolean;
|
|
|
|
|
|
2026-03-30 14:17:31 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
*/
|
2026-10-16 12:07:08 +02:00
|
|
|
|
2026-03-30 14:17:31 +02:00
|
|
|
setSelectedValues: (values: number[]) => void;
|
|
|
|
|
/**
|
|
|
|
|
* Clear all selected values
|
|
|
|
|
*/
|
|
|
|
|
clear: () => void;
|
|
|
|
|
/**
|
|
|
|
|
* Focus the component
|
|
|
|
|
*/
|
|
|
|
|
focus: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
export const ModelSelect = ({
|
|
|
|
|
ref,
|
|
|
|
|
name,
|
|
|
|
|
label,
|
|
|
|
|
required = true,
|
|
|
|
|
setData,
|
|
|
|
|
multiple = true,
|
|
|
|
|
minSelection,
|
|
|
|
|
maxSelection,
|
|
|
|
|
helper,
|
|
|
|
|
onValueChange,
|
|
|
|
|
onModelChange,
|
|
|
|
|
variant,
|
|
|
|
|
defaultValues = [],
|
|
|
|
|
placeholder = "Select options",
|
|
|
|
|
animation = 0,
|
|
|
|
|
animationConfig,
|
|
|
|
|
maxCount = 3,
|
|
|
|
|
modalPopover = false,
|
|
|
|
|
asChild: _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 = true,
|
|
|
|
|
emptyButtonVisible = true,
|
|
|
|
|
targetModel,
|
|
|
|
|
field,
|
|
|
|
|
exclude = null,
|
|
|
|
|
parentErrors = null,
|
|
|
|
|
...props
|
|
|
|
|
}: ModelSelectProps & { ref?: React.RefObject<ModelSelectRef | null> }) => {
|
|
|
|
|
const [selectedValues, setSelectedValues] = useState<number[]>(defaultValues);
|
|
|
|
|
|
|
|
|
|
const [selectedModels, setSelectedModels] = useState<DefaultModel[]>([]);
|
|
|
|
|
|
|
|
|
|
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
|
|
|
|
const [isAnimating, setIsAnimating] = useState<boolean>(false);
|
|
|
|
|
const [searchValue, setSearchValue] = useState<string>("");
|
|
|
|
|
|
|
|
|
|
const [politeMessage, setPoliteMessage] = useState<string>("");
|
|
|
|
|
const [assertiveMessage, setAssertiveMessage] = useState<string>("");
|
|
|
|
|
const prevSelectedCountRef = useRef(selectedValues.length);
|
|
|
|
|
const prevIsOpenRef = useRef(isPopoverOpen);
|
|
|
|
|
const prevSearchValueRef = useRef(searchValue);
|
|
|
|
|
|
|
|
|
|
const { errors } = usePage().props;
|
|
|
|
|
const [errorsAjax, setErrorsAjax] = useState<Record<string, string> | null>(
|
|
|
|
|
null,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [prevIsPopoverOpen, setPrevIsPopoverOpen] = useState(isPopoverOpen);
|
|
|
|
|
if (isPopoverOpen !== prevIsPopoverOpen) {
|
|
|
|
|
setPrevIsPopoverOpen(isPopoverOpen);
|
|
|
|
|
if (!isPopoverOpen) {
|
|
|
|
|
setSearchValue("");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
const localErrors = parentErrors ?? errors;
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
// Pagination.
|
|
|
|
|
const [page, setPage] = useState<number>(1);
|
|
|
|
|
const [pageSize] = useState<number>(5);
|
|
|
|
|
const [lastPage, setLastPage] = useState<number>(1);
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
// Search identification.
|
|
|
|
|
const requestIdRef = useRef(0);
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
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,
|
|
|
|
|
);
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
const { locale } = usePage().props.appEnv as AppEnv;
|
|
|
|
|
const { routes } = usePage().props.configBackend as ConfigBackend;
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
/**
|
|
|
|
|
* Set call ajax.
|
|
|
|
|
* @param pageNumber number
|
|
|
|
|
* @return
|
|
|
|
|
*/
|
|
|
|
|
const fetchOptions = async (pageNumber: number) => {
|
|
|
|
|
const csrfToken =
|
|
|
|
|
document
|
|
|
|
|
.querySelector('meta[name="csrf-token"]')
|
|
|
|
|
?.getAttribute("content") ?? "";
|
|
|
|
|
|
|
|
|
|
const callOptions = {
|
|
|
|
|
model: targetModel,
|
|
|
|
|
selectedIds: selectedValues,
|
|
|
|
|
search: searchValue,
|
|
|
|
|
field: dynamicField,
|
|
|
|
|
page: pageNumber,
|
|
|
|
|
pageSize,
|
|
|
|
|
exclude,
|
|
|
|
|
};
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
const callHeaders = {
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
Accept: "application/json",
|
|
|
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
|
|
|
"X-Inertia": "false",
|
|
|
|
|
"X-CSRF-TOKEN": csrfToken,
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
const response = await window.axios.post(
|
|
|
|
|
route(`${routes.name}model.list`),
|
|
|
|
|
callOptions,
|
|
|
|
|
callHeaders,
|
2026-03-30 14:17:31 +02:00
|
|
|
);
|
|
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
return response.data as {
|
|
|
|
|
items: Paginator;
|
|
|
|
|
field: string;
|
|
|
|
|
model: DefaultModel[] | null;
|
2026-03-30 14:17:31 +02:00
|
|
|
};
|
2026-10-16 12:07:08 +02:00
|
|
|
};
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
// Ignore obsolete answers.
|
|
|
|
|
if (requestId !== requestIdRef.current) return;
|
|
|
|
|
const dataModel = data.model ? Object.values(data.model) : [];
|
|
|
|
|
const dataItems: DefaultModel[] = Array.isArray(data.items.data)
|
|
|
|
|
? data.items.data
|
|
|
|
|
: [];
|
|
|
|
|
setOptionsMerged(() => {
|
|
|
|
|
const merged = [...dataModel, ...dataItems]
|
|
|
|
|
// Supprime les doublons par id
|
|
|
|
|
.filter(
|
|
|
|
|
(item, index, self) =>
|
|
|
|
|
index === self.findIndex((selfIndex) => selfIndex.id === item.id),
|
|
|
|
|
);
|
|
|
|
|
return merged;
|
|
|
|
|
});
|
|
|
|
|
setSelectedModels(dataModel);
|
|
|
|
|
setDynamicField(data.field);
|
|
|
|
|
setOptions(data.items.data);
|
|
|
|
|
setPage(data.items.current_page);
|
|
|
|
|
setLastPage(data.items.last_page);
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
// Ignore obsolete errors.
|
|
|
|
|
if (requestId !== requestIdRef.current) return;
|
|
|
|
|
console.error(err);
|
|
|
|
|
if (window.axios.isAxiosError(err) && err.response?.data) {
|
|
|
|
|
const responseData = err.response.data as {
|
|
|
|
|
errors: Record<string, string>;
|
|
|
|
|
} | null;
|
|
|
|
|
if (responseData) setErrorsAjax(responseData.errors);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
setOptions([]);
|
|
|
|
|
setLastPage(1);
|
|
|
|
|
} finally {
|
|
|
|
|
// Only the last call can stop the loading.
|
|
|
|
|
if (requestId === requestIdRef.current) {
|
|
|
|
|
setLoading(false);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
setInitialLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
/**
|
|
|
|
|
* 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) {
|
|
|
|
|
void 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(() => void getData(), 300);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
clearTimeout(handler);
|
|
|
|
|
};
|
|
|
|
|
// 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);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const multiSelectId = useId();
|
|
|
|
|
const listboxId = `${multiSelectId}-listbox`;
|
|
|
|
|
const triggerDescriptionId = `${multiSelectId}-description`;
|
|
|
|
|
const selectedCountId = `${multiSelectId}-count`;
|
|
|
|
|
|
|
|
|
|
// Translations.
|
2026-10-19 17:43:33 +02:00
|
|
|
const transDelete = trans("uwil-backend::crud.actions.delete");
|
|
|
|
|
const transClose = trans("uwil-backend::back.close");
|
|
|
|
|
const transClear = trans("uwil-backend::back.clear");
|
|
|
|
|
const transNoResults = trans("uwil-backend::back.table_no_results");
|
|
|
|
|
const transSearchOptions = trans(
|
2026-10-16 12:07:08 +02:00
|
|
|
"uwil-backend::back.multi_select.search_options",
|
|
|
|
|
);
|
2026-10-19 17:43:33 +02:00
|
|
|
const transSelectAll = trans("uwil-backend::back.multi_select.select_all");
|
|
|
|
|
const transMore = trans("uwil-backend::back.multi_select.more");
|
|
|
|
|
const transClosePopover = trans(
|
2026-10-16 12:07:08 +02:00
|
|
|
"uwil-backend::back.multi_select.close_popover",
|
|
|
|
|
);
|
2026-10-19 17:43:33 +02:00
|
|
|
const transClearList = trans("uwil-backend::back.multi_select.clear_list");
|
2026-10-16 12:07:08 +02:00
|
|
|
|
|
|
|
|
const [prevDefaultValue, setPrevDefaultValue] =
|
|
|
|
|
useState<number[]>(defaultValues);
|
|
|
|
|
|
|
|
|
|
const arraysEqual = useCallback((a: number[], b: number[]): boolean => {
|
|
|
|
|
if (a.length !== b.length) return false;
|
2026-10-19 17:43:33 +02:00
|
|
|
const sortedA = [...a].sort((x, y) => x - y);
|
|
|
|
|
const sortedB = [...b].sort((x, y) => x - y);
|
2026-10-16 12:07:08 +02:00
|
|
|
return sortedA.every((val, index) => val === sortedB[index]);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
resetOnDefaultValueChange &&
|
|
|
|
|
!arraysEqual(prevDefaultValue, defaultValues)
|
|
|
|
|
) {
|
|
|
|
|
setPrevDefaultValue([...defaultValues]);
|
|
|
|
|
if (!arraysEqual(selectedValues, defaultValues)) {
|
|
|
|
|
setSelectedValues(defaultValues);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
setData(name, multiple ? values : values[0]);
|
|
|
|
|
},
|
|
|
|
|
clear: () => {
|
|
|
|
|
setSelectedValues([]);
|
|
|
|
|
if (onValueChange) {
|
|
|
|
|
onValueChange([]);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
setData(name, multiple ? [] : null);
|
2026-03-30 14:17:31 +02:00
|
|
|
},
|
2026-10-16 12:07:08 +02:00
|
|
|
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">(
|
|
|
|
|
() => {
|
|
|
|
|
if (typeof window === "undefined") return "desktop";
|
|
|
|
|
const width = window.innerWidth;
|
|
|
|
|
if (width < 640) return "mobile";
|
|
|
|
|
if (width < 1024) return "tablet";
|
|
|
|
|
return "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");
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
};
|
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener("resize", handleResize);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
2026-04-07 16:21:09 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
const getResponsiveSettings = () => {
|
|
|
|
|
if (!responsive) {
|
|
|
|
|
return {
|
|
|
|
|
maxCount,
|
|
|
|
|
hideIcons: false,
|
|
|
|
|
compactMode: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (responsive === true) {
|
|
|
|
|
const defaultResponsive = {
|
|
|
|
|
mobile: {
|
|
|
|
|
maxCount: 2,
|
|
|
|
|
hideIcons: false,
|
|
|
|
|
compactMode: true,
|
2026-03-30 14:17:31 +02:00
|
|
|
},
|
2026-10-16 12:07:08 +02:00
|
|
|
tablet: {
|
|
|
|
|
maxCount: 4,
|
|
|
|
|
hideIcons: false,
|
|
|
|
|
compactMode: false,
|
2026-03-30 14:17:31 +02:00
|
|
|
},
|
2026-10-16 12:07:08 +02:00
|
|
|
desktop: {
|
|
|
|
|
maxCount: 6,
|
2026-03-30 14:17:31 +02:00
|
|
|
hideIcons: false,
|
|
|
|
|
compactMode: false,
|
2026-10-16 12:07:08 +02:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
const currentSettings = defaultResponsive[screenSize];
|
2026-03-30 14:17:31 +02:00
|
|
|
return {
|
2026-10-16 12:07:08 +02:00
|
|
|
maxCount: currentSettings.maxCount,
|
|
|
|
|
hideIcons: currentSettings.hideIcons,
|
|
|
|
|
compactMode: currentSettings.compactMode,
|
2026-03-30 14:17:31 +02:00
|
|
|
};
|
2026-10-16 12:07:08 +02:00
|
|
|
}
|
|
|
|
|
const currentSettings = responsive[screenSize];
|
|
|
|
|
return {
|
|
|
|
|
maxCount: currentSettings?.maxCount ?? maxCount,
|
|
|
|
|
hideIcons: currentSettings?.hideIcons ?? false,
|
|
|
|
|
compactMode: currentSettings?.compactMode ?? false,
|
2026-03-30 14:17:31 +02:00
|
|
|
};
|
2026-10-16 12:07:08 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 "";
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
}
|
|
|
|
|
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 "";
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
}
|
|
|
|
|
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) {
|
2026-03-30 14:17:31 +02:00
|
|
|
uniqueOptions.push(option);
|
|
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
} else {
|
|
|
|
|
valueSet.add(option.id.toString());
|
|
|
|
|
uniqueOptions.push(option);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (import.meta.env.MODE === "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 && import.meta.env.MODE === "development") {
|
2026-03-30 14:17:31 +02:00
|
|
|
console.warn(
|
2026-10-16 12:07:08 +02:00
|
|
|
`MultiSelect: Option with value "${String(id)}" not found in options list`,
|
2026-03-30 14:17:31 +02:00
|
|
|
);
|
|
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
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);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(newSelectedModels);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
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);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
const newSelectedModels = selectedModels.includes(option) ? [] : [option];
|
|
|
|
|
setSelectedModels(newSelectedModels);
|
|
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(newSelectedModels);
|
|
|
|
|
}
|
|
|
|
|
setData(name, newSelectedValues[0]);
|
2026-03-30 14:17:31 +02:00
|
|
|
if (closeOnSelect) {
|
|
|
|
|
setIsPopoverOpen(false);
|
|
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
// MODE MULTIPLE SELECT
|
|
|
|
|
const isCurrentlySelected = selectedValues.includes(option.id);
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
if (isCurrentlySelected) {
|
|
|
|
|
if (minSelection && selectedValues.length <= minSelection) {
|
|
|
|
|
announce(
|
|
|
|
|
`Minimum ${String(minSelection)} selection(s) required`,
|
|
|
|
|
"assertive",
|
|
|
|
|
);
|
|
|
|
|
return;
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
const newSelectedValues = selectedValues.filter(
|
|
|
|
|
(value) => value !== option.id,
|
2026-03-30 14:17:31 +02:00
|
|
|
);
|
|
|
|
|
setSelectedValues(newSelectedValues);
|
|
|
|
|
if (onValueChange) {
|
|
|
|
|
onValueChange(newSelectedValues);
|
|
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
const newSelectedModels = selectedModels.filter(
|
|
|
|
|
(value) => value.id !== option.id,
|
2026-03-30 14:17:31 +02:00
|
|
|
);
|
|
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(newSelectedModels);
|
|
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
setSelectedModels(newSelectedModels);
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
setData(name, newSelectedValues);
|
|
|
|
|
} else {
|
|
|
|
|
if (maxSelection && selectedValues.length >= maxSelection) {
|
|
|
|
|
announce(
|
|
|
|
|
`Maximum ${String(maxSelection)} selection(s) allowed`,
|
|
|
|
|
"assertive",
|
|
|
|
|
);
|
|
|
|
|
return;
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
const newSelectedValues = [...selectedValues, option.id];
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
setSelectedValues(newSelectedValues);
|
|
|
|
|
if (onValueChange) {
|
|
|
|
|
onValueChange(newSelectedValues);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
const newSelectedModels = [...selectedModels, option];
|
|
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(newSelectedModels);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
setSelectedModels(newSelectedModels);
|
|
|
|
|
setData(name, newSelectedValues);
|
|
|
|
|
}
|
|
|
|
|
if (closeOnSelect) {
|
|
|
|
|
setIsPopoverOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleClear = () => {
|
|
|
|
|
if (disabled) return;
|
|
|
|
|
if (multiple && minSelection && minSelection > 0) {
|
|
|
|
|
announce(
|
|
|
|
|
`Minimum ${String(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,
|
2026-03-30 14:17:31 +02:00
|
|
|
);
|
2026-10-16 12:07:08 +02:00
|
|
|
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 ${String(minSelection)} selection(s) required`,
|
|
|
|
|
"assertive",
|
2026-03-30 14:17:31 +02:00
|
|
|
);
|
2026-10-16 12:07:08 +02:00
|
|
|
return;
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
handleClear();
|
|
|
|
|
} else {
|
|
|
|
|
const allValues = allOptions.map((option) => option.id);
|
|
|
|
|
if (maxSelection && allValues.length > maxSelection) {
|
|
|
|
|
announce(
|
|
|
|
|
`Maximum ${String(maxSelection)} selection(s) allowed`,
|
|
|
|
|
"assertive",
|
2026-03-30 14:17:31 +02:00
|
|
|
);
|
2026-10-16 12:07:08 +02:00
|
|
|
return;
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
setSelectedValues(allValues);
|
|
|
|
|
if (onValueChange) {
|
|
|
|
|
onValueChange(allValues);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
setSelectedModels(allOptions);
|
|
|
|
|
if (onModelChange) {
|
|
|
|
|
onModelChange(allOptions);
|
2026-03-30 14:17:31 +02:00
|
|
|
}
|
2026-10-16 12:07:08 +02:00
|
|
|
setData(name, allValues);
|
|
|
|
|
}
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
if (closeOnSelect) {
|
|
|
|
|
setIsPopoverOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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%",
|
2026-03-30 14:17:31 +02:00
|
|
|
};
|
2026-10-16 12:07:08 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const widthConstraints = getWidthConstraints();
|
|
|
|
|
|
|
|
|
|
const getModelField = (option: DefaultModel): string => {
|
|
|
|
|
const value = option[dynamicField as keyof DefaultModel] as unknown as
|
|
|
|
|
Record<string, string> | string;
|
|
|
|
|
if (typeof value === "object") return value[locale] ?? "";
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAnnouncements = useCallback(() => {
|
|
|
|
|
const selectedCount = selectedValues.length;
|
|
|
|
|
const allOptions = getAllOptions();
|
|
|
|
|
const totalOptions = allOptions.length;
|
|
|
|
|
if (selectedCount !== prevSelectedCountRef.current) {
|
|
|
|
|
const diff = selectedCount - prevSelectedCountRef.current;
|
|
|
|
|
if (diff > 0) {
|
|
|
|
|
const addedItems = selectedValues.slice(-diff);
|
|
|
|
|
const addedLabels = addedItems
|
|
|
|
|
.map((value) => {
|
|
|
|
|
const opt = allOptions.find((o) => o.id === value);
|
|
|
|
|
return opt ? getModelField(opt) : undefined;
|
|
|
|
|
})
|
|
|
|
|
.filter((v): v is string => v !== undefined);
|
|
|
|
|
|
|
|
|
|
if (addedLabels.length === 1) {
|
|
|
|
|
announce(
|
|
|
|
|
`${addedLabels[0]} selected. ${String(selectedCount)} of ${String(totalOptions)} options selected.`,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
announce(
|
|
|
|
|
`${String(addedLabels.length)} options selected. ${String(selectedCount)} of ${String(totalOptions)} total selected.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} else if (diff < 0) {
|
|
|
|
|
announce(
|
|
|
|
|
`Option removed. ${String(selectedCount)} of ${String(totalOptions)} options selected.`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
prevSelectedCountRef.current = selectedCount;
|
|
|
|
|
}
|
2026-03-30 14:17:31 +02:00
|
|
|
|
2026-10-16 12:07:08 +02:00
|
|
|
if (isPopoverOpen !== prevIsOpenRef.current) {
|
|
|
|
|
if (isPopoverOpen) {
|
|
|
|
|
announce(
|
|
|
|
|
`Dropdown opened. ${String(totalOptions)} options available. Use arrow keys to navigate.`,
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
announce("Dropdown closed.");
|
|
|
|
|
}
|
|
|
|
|
prevIsOpenRef.current = isPopoverOpen;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (searchValue !== prevSearchValueRef.current) {
|
|
|
|
|
if (searchValue && isPopoverOpen) {
|
|
|
|
|
const filteredCount = allOptions.length;
|
|
|
|
|
announce(
|
|
|
|
|
`${String(filteredCount)} option${
|
|
|
|
|
filteredCount === 1 ? "" : "s"
|
|
|
|
|
} found for "${searchValue}"`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
prevSearchValueRef.current = searchValue;
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [selectedValues, isPopoverOpen, searchValue, announce, getAllOptions]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
handleAnnouncements();
|
|
|
|
|
}, [handleAnnouncements]);
|
|
|
|
|
|
|
|
|
|
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 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}
|
2026-03-30 14:17:31 +02:00
|
|
|
</div>
|
2026-10-16 12:07:08 +02:00
|
|
|
<div aria-live="assertive" aria-atomic="true" role="alert">
|
|
|
|
|
{assertiveMessage}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-10-19 16:24:15 +02:00
|
|
|
<Label htmlFor={name} className="font-bold flex items-center gap-1.5">
|
2026-10-16 12:07:08 +02:00
|
|
|
{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"
|
|
|
|
|
: `${String(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}
|
|
|
|
|
{...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: ${String(selectedValues.length)} of ${String(
|
|
|
|
|
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 text-muted-foreground 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: `${String(animation)}s`,
|
|
|
|
|
};
|
|
|
|
|
return multiple ? (
|
|
|
|
|
<Badge
|
|
|
|
|
key={value}
|
|
|
|
|
className={cn(
|
|
|
|
|
getBadgeAnimationClass(),
|
|
|
|
|
modelSelectVariants({
|
|
|
|
|
variant: variant,
|
|
|
|
|
}),
|
|
|
|
|
responsiveSettings.compactMode &&
|
|
|
|
|
"text-xs px-1.5 py-0.5",
|
|
|
|
|
screenSize === "mobile" &&
|
|
|
|
|
"max-w-[120px] truncate",
|
|
|
|
|
singleLine && "shrink-0 whitespace-nowrap",
|
|
|
|
|
"[&>svg]:pointer-events-auto",
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
...badgeStyle,
|
|
|
|
|
animationDuration: `${String(
|
|
|
|
|
animationConfig?.duration ?? animation,
|
|
|
|
|
)}s`,
|
|
|
|
|
animationDelay: `${String(animationConfig?.delay ?? 0)}s`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span
|
2026-03-30 14:17:31 +02:00
|
|
|
className={cn(
|
2026-10-16 12:07:08 +02:00
|
|
|
screenSize === "mobile" && "truncate",
|
2026-03-30 14:17:31 +02:00
|
|
|
)}
|
|
|
|
|
>
|
2026-10-16 12:07:08 +02:00
|
|
|
{getModelField(option)}
|
|
|
|
|
</span>
|
|
|
|
|
<div
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
toggleOption(option);
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (
|
|
|
|
|
event.key === "Enter" ||
|
|
|
|
|
event.key === " "
|
|
|
|
|
) {
|
|
|
|
|
event.preventDefault();
|
2026-03-30 14:17:31 +02:00
|
|
|
event.stopPropagation();
|
|
|
|
|
toggleOption(option);
|
2026-10-16 12:07:08 +02:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Remove ${getModelField(option)} from selection`}
|
|
|
|
|
className="ml-2 h-4 w-4 cursor-pointer hover:bg-white/20 rounded-xs -m-0.5 focus:outline-hidden focus:ring-1 focus:ring-white/50"
|
2026-03-30 14:17:31 +02:00
|
|
|
>
|
2026-10-16 12:07:08 +02:00
|
|
|
<XCircleIcon
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-3 w-3",
|
|
|
|
|
responsiveSettings.compactMode &&
|
|
|
|
|
"h-2.5 w-2.5",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</Badge>
|
|
|
|
|
) : (
|
|
|
|
|
<p
|
|
|
|
|
key={value}
|
2026-03-30 14:17:31 +02:00
|
|
|
className={cn(
|
2026-10-16 12:07:08 +02:00
|
|
|
"flex items-center overflow-hidden whitespace-nowrap max-w-[180px]",
|
|
|
|
|
singleLine && "shrink-0",
|
|
|
|
|
screenSize === "mobile" && "max-w-[120px]",
|
|
|
|
|
responsiveSettings.compactMode &&
|
|
|
|
|
"text-xs px-1.5 py-0.5",
|
2026-03-30 14:17:31 +02:00
|
|
|
)}
|
2026-10-16 12:07:08 +02:00
|
|
|
>
|
|
|
|
|
<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 && "shrink-0 whitespace-nowrap",
|
|
|
|
|
"[&>svg]:pointer-events-auto",
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
animationDuration: `${String(animationConfig?.duration ?? animation)}s`,
|
|
|
|
|
animationDelay: `${String(animationConfig?.delay ?? 0)}s`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{`+ ${String(
|
|
|
|
|
selectedValues.length - responsiveSettings.maxCount,
|
|
|
|
|
)} ${transMore}`}
|
|
|
|
|
<XCircleIcon
|
|
|
|
|
className={cn(
|
|
|
|
|
"ml-2 h-4 w-4 cursor-pointer",
|
|
|
|
|
responsiveSettings.compactMode && "ml-1 h-3 w-3",
|
|
|
|
|
)}
|
2026-03-30 14:17:31 +02:00
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
2026-10-16 12:07:08 +02:00
|
|
|
clearExtraOptions();
|
2026-03-30 14:17:31 +02:00
|
|
|
}}
|
2026-10-16 12:07:08 +02:00
|
|
|
/>
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
2026-03-30 14:17:31 +02:00
|
|
|
</div>
|
2026-10-16 12:07:08 +02:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
{!required && (
|
2026-10-19 15:43:36 +02:00
|
|
|
<span
|
2026-10-16 12:07:08 +02:00
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={(event) => {
|
2026-10-19 15:43:36 +02:00
|
|
|
event.preventDefault();
|
2026-10-16 12:07:08 +02:00
|
|
|
event.stopPropagation();
|
|
|
|
|
handleClear();
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
handleClear();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Clear all ${String(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-xs"
|
|
|
|
|
>
|
|
|
|
|
<XIcon className="h-4 w-4" />
|
2026-10-19 15:43:36 +02:00
|
|
|
</span>
|
2026-10-16 12:07:08 +02:00
|
|
|
)}
|
|
|
|
|
<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"
|
|
|
|
|
/>
|
2026-03-30 14:17:31 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-10-16 12:07:08 +02:00
|
|
|
))}
|
|
|
|
|
</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: `${String(animationConfig?.duration ?? animation)}s`,
|
|
|
|
|
animationDelay: `${String(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" />
|
2026-03-30 14:17:31 +02:00
|
|
|
</div>
|
2026-10-16 12:07:08 +02:00
|
|
|
<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>
|
2026-03-30 14:17:31 +02:00
|
|
|
)}
|
2026-10-16 12:07:08 +02:00
|
|
|
{/* 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 ${String(
|
|
|
|
|
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-xs border! border-primary!",
|
2026-03-30 14:17:31 +02:00
|
|
|
selectedValues.length === getAllOptions().length
|
2026-10-16 12:07:08 +02:00
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
: "opacity-50 [&_svg]:invisible",
|
|
|
|
|
)}
|
|
|
|
|
aria-hidden="true"
|
2026-03-30 14:17:31 +02:00
|
|
|
>
|
2026-10-16 12:07:08 +02:00
|
|
|
<CheckIcon
|
|
|
|
|
className="h-4 w-4 text-white"
|
|
|
|
|
strokeWidth={4}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<span>
|
|
|
|
|
({str(transSelectAll).ucFirst().value()}
|
|
|
|
|
{getAllOptions().length > 20
|
|
|
|
|
? ` - ${String(getAllOptions().length)} options`
|
|
|
|
|
: ""}
|
|
|
|
|
)
|
|
|
|
|
</span>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
)}
|
|
|
|
|
{/* List of items */}
|
|
|
|
|
<CommandGroup className="p-0">
|
|
|
|
|
{loading
|
|
|
|
|
? Array.from({ length: pageSize }).map((_, index) => (
|
|
|
|
|
// eslint-disable-next-line react/no-array-index-key
|
|
|
|
|
<Skeleton key={index} 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",
|
|
|
|
|
{ "justify-between": !multiple },
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{multiple && (
|
2026-03-30 14:17:31 +02:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-10-16 12:07:08 +02:00
|
|
|
"flex h-4 w-4 items-center justify-center rounded-xs border! border-primary!",
|
2026-03-30 14:17:31 +02:00
|
|
|
isSelected
|
|
|
|
|
? "bg-primary text-primary-foreground"
|
|
|
|
|
: "opacity-50 [&_svg]:invisible",
|
|
|
|
|
)}
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
<CheckIcon
|
|
|
|
|
className="h-4 w-4 text-white"
|
|
|
|
|
strokeWidth={4}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-10-16 12:07:08 +02:00
|
|
|
)}
|
|
|
|
|
<span>{getModelField(option)}</span>
|
|
|
|
|
{!multiple && isSelected && (
|
|
|
|
|
<CheckIcon className="text-dark" />
|
|
|
|
|
)}
|
|
|
|
|
</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 />
|
2026-10-19 17:43:33 +02:00
|
|
|
{trans("uwil-backend::pagination.previous")}
|
2026-10-16 12:07:08 +02:00
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
tooltipContent={
|
|
|
|
|
<p>
|
2026-10-19 17:43:33 +02:00
|
|
|
{trans("uwil-backend::pagination.specific_page", {
|
|
|
|
|
params: {
|
|
|
|
|
id: String(page - 1),
|
2026-10-16 12:07:08 +02:00
|
|
|
},
|
2026-10-19 17:43:33 +02:00
|
|
|
})}
|
2026-10-16 12:07:08 +02:00
|
|
|
</p>
|
|
|
|
|
}
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => void 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={
|
|
|
|
|
<>
|
2026-10-19 17:43:33 +02:00
|
|
|
{trans("uwil-backend::pagination.next")}
|
2026-10-16 12:07:08 +02:00
|
|
|
<ChevronRightIcon />
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
tooltipContent={
|
|
|
|
|
<p>
|
2026-10-19 17:43:33 +02:00
|
|
|
{trans("uwil-backend::pagination.specific_page", {
|
|
|
|
|
params: {
|
|
|
|
|
id: String(page + 1),
|
2026-10-16 12:07:08 +02:00
|
|
|
},
|
2026-10-19 17:43:33 +02:00
|
|
|
})}
|
2026-10-16 12:07:08 +02:00
|
|
|
</p>
|
|
|
|
|
}
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => void goToPage(page + 1)}
|
|
|
|
|
disabled={page >= lastPage || loading}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{(closeButtonVisible ||
|
|
|
|
|
(emptyButtonVisible && selectedValues.length > 0)) && (
|
|
|
|
|
<CommandGroup className="border-t p-1">
|
|
|
|
|
<div className="flex items-center justify-between gap-1">
|
|
|
|
|
{emptyButtonVisible && 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}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-03-30 14:17:31 +02:00
|
|
|
</div>
|
2026-10-16 12:07:08 +02:00
|
|
|
</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={errorsAjax ? "une erreur est survenue" : localErrors[name]}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
2026-03-30 14:17:31 +02:00
|
|
|
|
|
|
|
|
ModelSelect.displayName = "ModelSelect";
|
2026-10-16 12:07:08 +02:00
|
|
|
|
2026-03-30 14:17:31 +02:00
|
|
|
export type { ModelSelectProps };
|