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