ldc-groupe/resources/ts/back/Components/Inputs/EnumSelect.tsx
2026-02-19 14:06:28 +01:00

250 lines
9.5 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;
// eslint-disable-next-line no-unused-vars
setData: (name: string, value: number | null) => void;
// eslint-disable-next-line no-unused-vars
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(
([, 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);
const transNoResults = useTrans("back.table_no_results");
const transDelete = useTrans("crud.actions.delete");
/**
* Get the identification field of specific option.
* @param value number
* @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(([, item]) =>
item.label.toLowerCase().includes(search),
),
);
// Get updated options list with search.
setCurrentOptions(optionsFiltered);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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
? str(getEnumLabel(selectedEnum)).ucFirst().value()
: 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">
{transNoResults}
</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>{str(option.label).ucFirst().value()}</span>
{selectedEnum === option.value && (
<CheckIcon />
)}
</Button>
))}
</div>
</div>
</PopoverContent>
</Popover>
{!required && selectedEnum !== null && (
<BtnOptionnalLinkWithTooltip
content={<XIcon />}
tooltipContent={
<p>{str(transDelete).ucFirst().value()}</p>
}
type="button"
variant="destructive"
size="icon"
onClick={handleClear}
/>
)}
</div>
<InputError message={localErrors[name]} />
</div>
);
}