Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/ui/input/InputCombobox.tsx
T

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);