ldc-groupe/resources/ts/back/Components/Inputs/EnumSelect.tsx
2026-01-21 16:46:27 +01:00

211 lines
7.1 KiB
TypeScript

// * Packages
import { usePage } from "@inertiajs/react";
import { useEffect, useState } from "react";
import { CheckIcon, ChevronsUpDownIcon, SearchIcon, XIcon } from "lucide-react";
// * Components shadcn
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
// * Other
import BtnOptionnalLinkWithTooltip from "@/back/Components//BtnOptionnalLinkWithTooltip";
import InputError from "@/back/Components/InputError";
import { AppEnv } from "@/back/types/global";
import { cn } from "@/lib/utils";
import useTrans from "@/hooks/use-translations";
import str from "@/hooks/use-string";
type EnumSelectProps = {
name: string;
label: string;
options?: Record<string, { label: string; name: string; value: number }>;
placeholder?: string;
exclude?: number[];
selected?: number|null;
required?: boolean;
disabled?: boolean;
parentErrors?: Record<string, string> | null,
setData: (name: string, value: number | null) => void;
onSelectValue?: (value: number | null) => void;
};
export default function EnumSelect({
name,
label,
options = {},
placeholder = "ex: Magnifique libellé",
exclude = [],
selected = null,
required = true,
disabled = false,
parentErrors = null,
setData,
onSelectValue,
}: EnumSelectProps) {
const { locale } = usePage().props.appEnv as AppEnv;
const { errors } = usePage().props;
const localErrors = parentErrors ?? errors;
// List of options.
const initalOptions = Object.fromEntries(
Object.entries(options).filter(([key, item]) =>
!exclude.includes(item.value)
)
)
const [currentOptions, setCurrentOptions] = useState<Record<string, { label: string; name: string; value: number }>>(initalOptions);
// Model key selected.
const initial = selected === 0 || selected === null || selected === undefined ? null : Number(selected);
const [selectedEnum, setSelectedEnum] = useState<number|null>(initial);
// String research in models collection.
const [search, setSearch] = useState<string>("");
// Component states.
const [open, setOpen] = useState<boolean>(false);
/**
* Get the identification field of specific option.
* @param option Model
* @return string
*/
const getEnumLabel = (value: number): string => {
const targetEnum = Object.values(initalOptions).find(option => option.value === value);
if (!targetEnum) return String(value);
if (typeof value === "object") return String(targetEnum.label[locale as keyof typeof targetEnum.label] ?? "");
return targetEnum.label;
};
/**
* Updates when selecting new value.
* @param value number
* @return void
*/
const handleSelect = (value: number) => {
setSelectedEnum(value);
setData(name, value);
onSelectValue?.(value);
setOpen(false);
};
/**
* Updates when clear the selected value.
* @return void
*/
const handleClear = () => {
setSelectedEnum(null);
setData(name, null);
onSelectValue?.(null);
};
/**
* Reset the search.
* @return void
*/
const handleClearSearch = () => {
setSearch("");
};
// Fetch with debounce.
useEffect(() => {
// Reset options list if empty search or null
if (search.length === 0) {
setCurrentOptions(initalOptions);
return;
}
const optionsFiltered = Object.fromEntries(
Object.entries(initalOptions).filter(([key, item]) =>
item.label.toLowerCase().includes(search)
)
);
// Get updated options list with search.
setCurrentOptions(optionsFiltered);
}, [search, options]);
return (
<div className="flex flex-col w-full gap-1.5">
<Label htmlFor={name}>
{label} {required && "*"}
</Label>
<div className="flex items-center gap-1.5 w-full">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full h-10 justify-between whitespace-normal text-start",
{ "text-muted-foreground": !selectedEnum },
{ "border-destructive/50": localErrors[name] },
)}
>
{selectedEnum ? getEnumLabel(selectedEnum) : placeholder}
<ChevronsUpDownIcon className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<div className="relative">
<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={placeholder}
value={search}
disabled={disabled}
onChange={(e) => setSearch(e.target.value)}
className="w-[calc(100%-2.5rem-40px)] border-0 mx-9"
/>
<BtnOptionnalLinkWithTooltip
content={<XIcon />}
tooltipContent={<p>{str(useTrans("crud.actions.delete")).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={handleClearSearch}
disabled={search.length < 1 || disabled}
/>
</div>
<div
className={cn(`overflow-auto border-t flex flex-col justify-between p-1 gap-1 ${Object.values(initalOptions).length > 5 ? "h-[225px]" : "h-auto"}`)}
>
{Object.values(currentOptions).length === 0 && (
<p className="text-sm text-muted-foreground text-center py-5">{useTrans("back.table_no_results")}</p>
)}
<div className="flex flex-col justify-between">
{Object.values(currentOptions).map((option) => (
<Button
key={option.value}
className="w-full h-auto min-h-10 justify-between whitespace-normal text-start"
variant="ghost"
disabled={disabled}
onClick={() => handleSelect(option.value)}
>
<span>{option.label}</span>
{selectedEnum === option.value && <CheckIcon />}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
{!required && selectedEnum !== null && (
<BtnOptionnalLinkWithTooltip
content={<XIcon />}
tooltipContent={<p>{str(useTrans("crud.actions.delete")).ucFirst().value()}</p>}
type="button"
variant="destructive"
size="icon"
onClick={handleClear}
/>
)}
</div>
<InputError message={localErrors[name]} />
</div>
);
}