import { Ref, forwardRef, useImperativeHandle, useRef, ReactNode, Fragment, useEffect, useMemo, useState } from "react"; import { Combobox, ComboboxButton, ComboboxOption, ComboboxOptions, Label, Transition } from "@headlessui/react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { Colors } from "~/application/enums/shared/Colors"; import ColorBadge from "../badges/ColorBadge"; import HintTooltip from "../tooltips/HintTooltip"; import EntityIcon from "~/components/layouts/icons/EntityIcon"; import { Input } from "../input"; import { CaretSortIcon, CheckIcon, SearchIcon, PlusIcon } from "~/components/ui/icons/vendor"; import { cn } from "~/lib/utils"; import { useLocaleFormatters } from "~/locale/format"; export interface RefInputCombobox { focus: () => void; } interface Props { name?: string; title?: string; value?: (string | number)[]; disabled?: boolean; options: { value: string | number | undefined; name?: string | ReactNode; color?: Colors; disabled?: boolean }[]; onChange?: (value: (string | number)[]) => void; className?: string; withSearch?: boolean; withLabel?: boolean; withColors?: boolean; selectPlaceholder?: string; onNew?: () => void; onNewRoute?: string; required?: boolean; help?: string; hint?: ReactNode; icon?: string; borderless?: boolean; darkMode?: boolean; autoFocus?: boolean; readOnly?: boolean; renderHiddenInputValue?: (item: string | number) => string; prefix?: string; onClear?: () => void; minDisplayCount?: number; } function selectionKey(values: (string | number)[]) { return values .map((value) => `${typeof value}:${String(value)}`) .sort() .join("|"); } const InputCombobox = ( { name, title, value, options, disabled, onChange, className, withSearch = true, withLabel = true, withColors = false, selectPlaceholder, onNew, required, onNewRoute, help, hint, icon, borderless, darkMode, autoFocus, readOnly, renderHiddenInputValue, prefix, onClear, minDisplayCount = 3, }: Props, ref: Ref ) => { const { t } = useTranslation(); const formatters = useLocaleFormatters(); const button = useRef(null); const inputSearch = useRef(null); const [selected, setSelected] = useState<(string | number)[]>(value || []); const [searchInput, setSearchInput] = useState(""); useEffect(() => { if (value && selectionKey(selected) !== selectionKey(value)) { setSelected([...value]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); useEffect(() => { if (onChange && selectionKey(selected) !== selectionKey(value ?? [])) { onChange(selected); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selected]); useImperativeHandle(ref, () => ({ focus })); function focus() { setTimeout(() => { button.current?.focus(); button.current?.click(); }, 1); } const filteredItems = useMemo(() => { if (!options) { return []; } const query = searchInput.toUpperCase(); return options.filter((f) => f.name?.toString().toUpperCase().includes(query) || f.value?.toString().toUpperCase().includes(query)); }, [options, searchInput]); const selectedValues = useMemo(() => new Set(selected), [selected]); const selectedOptions = useMemo(() => options.filter((f) => f.value !== undefined && selectedValues.has(f.value)), [options, selectedValues]); const newLabel = String(t("shared.new")); return (
{({ open }) => (
{/* {renderHiddenInputValue && <> {selected?.map((item, idx) => { return ; })} } */} {withLabel && title && ( )}
span]:line-clamp-1", className )} > {icon && (
)}
{/* {withColors && selected && } */}
{/* {JSON.stringify(selected)} */} {selected.length > 0 ? ( {prefix ?? ""} {selectedOptions.length < minDisplayCount ? selectedOptions.map((f) => f.name ?? f.value).join(", ") : t("shared.selectedCount", { count: selectedOptions.length, formattedCount: formatters.number(selectedOptions.length) })} ) : ( {selectPlaceholder ?? t("shared.select")}... )}
{withSearch && filteredItems.length > 10 && (
setSearchInput(e.target.value)} className="pl-10" />
)} inputSearch.current?.focus()} // onBlur={() => setSearchInput("")} // Subframe Combobox dropdown inner list: px-1 py-1, gap-0.5 between items. className="flex max-h-72 flex-col gap-0.5 overflow-auto px-1 py-1 focus:outline-hidden" > {filteredItems.map((item, idx) => ( cn( // Subframe Combobox.Item layout: cursor-pointer, rounded-sm, px-2 py-2, gap-2. // Radix/headlessui-only kept: pr-9 (room for absolute check), relative, focus states. "text-foreground hover:bg-secondary hover:text-secondary-foreground relative cursor-pointer gap-2 rounded-sm py-2 pr-9 pl-2 select-none focus:outline-hidden", !item.disabled && focus && "bg-background text-foreground", !item.disabled && !focus && " ", item.disabled && "text-muted-foreground bg-secondary/90 cursor-not-allowed", darkMode && !item.disabled && focus && "dark:bg-accent-500 dark:text-black", darkMode && !item.disabled && !focus && "dark:text-gray-50", darkMode && item.disabled && "dark:text-muted-foreground cursor-not-allowed dark:bg-gray-900" // selected && "bg-secondary text-secondary-foreground" ) } value={item.value} > {({ selected, active }) => (
{withColors && item.color !== undefined && }
{item.name || item.value}
{selected ? (
{/* */}
) : null}
)}
))} {withSearch && filteredItems.length === 0 &&
{t("shared.noRecords")}
}
)}
{onNew && ( )} {onNewRoute && (
); }; export default forwardRef(InputCombobox);