308 lines
14 KiB
TypeScript
308 lines
14 KiB
TypeScript
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<RefInputCombobox>
|
|
) => {
|
|
const { t } = useTranslation();
|
|
const formatters = useLocaleFormatters();
|
|
|
|
const button = useRef<HTMLButtonElement>(null);
|
|
const inputSearch = useRef<HTMLInputElement>(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 (
|
|
<div className="flex min-w-0 items-end gap-2">
|
|
<Combobox as={Fragment} multiple value={selected} onChange={setSelected} disabled={disabled || readOnly}>
|
|
{({ open }) => (
|
|
<div className="min-w-0 flex-1">
|
|
{/* {renderHiddenInputValue && <>
|
|
{selected?.map((item, idx) => {
|
|
return <input key={idx} type="hidden" name={name + `[]`} value={JSON.stringify(item)} />;
|
|
})}
|
|
</>} */}
|
|
|
|
{withLabel && title && (
|
|
<Label htmlFor={name} className="mb-1 flex min-w-0 justify-between gap-2 text-xs font-medium">
|
|
<div className="flex min-w-0 items-center space-x-1">
|
|
<div className="truncate">
|
|
{title}
|
|
{required && <span className="ml-1 text-red-600">*</span>}
|
|
</div>
|
|
|
|
{help && <HintTooltip text={help} />}
|
|
</div>
|
|
{hint}
|
|
{onClear && selected.length > 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={onClear}
|
|
className="text-muted-foreground hover:text-foreground/80 focus-visible:ring-ring inline-flex min-h-8 shrink-0 items-center rounded-md px-2 text-xs focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
>
|
|
{t("shared.clear")}
|
|
</button>
|
|
) : null}
|
|
</Label>
|
|
)}
|
|
|
|
<div className="relative">
|
|
<ComboboxButton
|
|
autoFocus={autoFocus}
|
|
type="button"
|
|
ref={button}
|
|
// className={cn(
|
|
// "focus:border-border focus:ring-ring relative w-full cursor-default rounded-md border border-border py-2 pl-3 pr-10 text-left shadow-2xs focus:outline-hidden focus:ring-1 sm:text-sm",
|
|
// disabled || readOnly ? "cursor-not-allowed bg-secondary/90" : "bg-background hover:bg-secondary focus:bg-secondary",
|
|
// borderless && "border-transparent",
|
|
// darkMode && "dark:border-border dark:bg-foreground/90"
|
|
// )}
|
|
className={cn(
|
|
// Subframe Combobox field box layout: h-9, rounded-sm, border, px-3, gap-2.
|
|
// Radix/headlessui-only kept: focus/ring, disabled, overflow, span clamp.
|
|
"border-input ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-sm border bg-transparent px-3 py-2 text-left text-sm whitespace-nowrap shadow-2xs focus:outline-hidden focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
className
|
|
)}
|
|
>
|
|
{icon && (
|
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<EntityIcon className="h-5 w-5" icon={icon} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="inline-flex min-w-0 flex-1 items-center space-x-2 truncate">
|
|
{/* {withColors && selected && <ColorBadge color={selected?.color ?? Colors.UNDEFINED} />} */}
|
|
<div className="truncate">
|
|
{/* {JSON.stringify(selected)} */}
|
|
{selected.length > 0 ? (
|
|
<span className="truncate">
|
|
{prefix ?? ""}
|
|
{selectedOptions.length < minDisplayCount
|
|
? selectedOptions.map((f) => f.name ?? f.value).join(", ")
|
|
: t("shared.selectedCount", { count: selectedOptions.length, formattedCount: formatters.number(selectedOptions.length) })}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground text-sm">{selectPlaceholder ?? t("shared.select")}...</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<CaretSortIcon className="h-4 w-4 shrink-0 opacity-50" aria-hidden="true" />
|
|
</ComboboxButton>
|
|
|
|
<Transition show={open} as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0">
|
|
<div className="bg-background text-foreground border-border absolute z-10 mt-1 w-full max-w-[calc(100vw-2rem)] overflow-hidden rounded-sm border text-base shadow-md sm:text-sm">
|
|
{withSearch && filteredItems.length > 10 && (
|
|
<div className="flex min-w-0 gap-2 rounded-md p-2">
|
|
<div className="relative flex min-w-0 grow items-stretch focus-within:z-10">
|
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<SearchIcon className="text-muted-foreground h-5 w-5" />
|
|
</div>
|
|
<Input
|
|
ref={inputSearch}
|
|
id="search"
|
|
autoComplete="off"
|
|
placeholder={t("shared.searchDot")}
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ComboboxOptions
|
|
// onFocus={() => 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) => (
|
|
<ComboboxOption
|
|
key={idx}
|
|
disabled={item.disabled}
|
|
className={({ focus }) =>
|
|
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 }) => (
|
|
<div className="flex min-w-0 items-center justify-between space-x-2">
|
|
<div className="flex min-w-0 items-center space-x-2 truncate">
|
|
{withColors && item.color !== undefined && <ColorBadge color={item.color} />}
|
|
<div className={cn(selected ? "font-normal" : "font-normal", "truncate")}>{item.name || item.value}</div>
|
|
</div>
|
|
|
|
{selected ? (
|
|
<div className={cn(active ? "" : "", "absolute inset-y-0 right-0 flex items-center pr-4")}>
|
|
<CheckIcon className="h-4 w-4" />
|
|
{/* <svg xmlns="http://www.w3.org/2000/svg" className="text-foreground h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg> */}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</ComboboxOption>
|
|
))}
|
|
|
|
{withSearch && filteredItems.length === 0 && <div className="text-muted-foreground px-3 py-2 text-sm">{t("shared.noRecords")}</div>}
|
|
</ComboboxOptions>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Combobox>
|
|
{onNew && (
|
|
<button
|
|
aria-label={newLabel}
|
|
title={newLabel}
|
|
type="button"
|
|
onClick={onNew}
|
|
className="focus-visible:border-border focus-visible:ring-ring relative inline-flex h-9 w-10 shrink-0 items-center justify-center rounded-md text-sm font-medium focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
>
|
|
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
{onNewRoute && (
|
|
<Link
|
|
to={onNewRoute}
|
|
aria-label={newLabel}
|
|
title={newLabel}
|
|
className="focus-visible:border-border focus-visible:ring-ring border-border bg-secondary hover:bg-secondary/90 text-foreground/80 relative inline-flex h-9 w-10 shrink-0 items-center justify-center rounded-md border text-sm font-medium focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
>
|
|
<PlusIcon className="h-4 w-4" aria-hidden="true" />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default forwardRef(InputCombobox);
|