Files
tailwind-ui-library/components/form/SearchSelect.tsx
T
2026-10-01 08:02:47 +01:00

621 lines
24 KiB
TypeScript

import { Check, ChevronDown, Search, X } from "lucide-react";
import React from "react";
import { twMerge } from "tailwind-merge";
import type {
TWUISelectOptionObject,
TWUISelectProps,
TWUISelectValidityObject,
} from "./Select";
import Span from "../layout/Span";
import twuiSlugify from "../utils/slugify";
function getOptionLabel<KeyType extends string, T extends { [k: string]: any }>(
option: TWUISelectOptionObject<KeyType, T>,
) {
return option.title || option.value;
}
function matchesQuery<KeyType extends string, T extends { [k: string]: any }>(
option: TWUISelectOptionObject<KeyType, T>,
query: string,
) {
const q = query.trim().toLowerCase();
if (!q) return true;
return [option.value, option.title || "", getOptionLabel(option)]
.map((s) => s.toLowerCase())
.some((h) => h.includes(q));
}
export type TWUISearchSelectProps<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any },
> = Omit<TWUISelectProps<KeyType, T>, "changeHandler" | "defaultValue"> & {
multiple?: boolean;
defaultValues?: KeyType[];
changeHandler?: (value: KeyType | KeyType[] | "", data?: T | T[]) => void;
placeholder?: string;
};
/**
* # Search Select Element
* Supports single and multiple selection (`multiple` prop).
* @className twui-search-select-wrapper
* @className twui-search-select
* @className twui-select-target-option
*/
export default function SearchSelect<
KeyType extends string,
T extends { [k: string]: any } = { [k: string]: any },
>({
label,
options,
componentRef,
labelProps,
wrapperProps,
showLabel,
iconProps,
changeHandler,
info,
validateValueFn,
wrapperWrapperProps,
dispatchState,
input_props,
option_button_props,
placeholder,
multiple = false,
defaultValues,
...props
}: TWUISearchSelectProps<KeyType, T>) {
const rootRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLUListElement>(null);
const internalSelectRef = React.useRef<HTMLSelectElement>(null);
const selectRef = componentRef || internalSelectRef;
const changeHandlerRef = React.useRef(changeHandler);
const dispatchStateRef = React.useRef(dispatchState);
changeHandlerRef.current = changeHandler;
dispatchStateRef.current = dispatchState;
const initialSelected = React.useMemo(() => {
if (defaultValues?.length) {
return options.filter((o) => defaultValues.includes(o.value));
}
const defaults = options.filter((o) => o.default);
if (multiple) return defaults;
return defaults[0] ? [defaults[0]] : [];
}, [options, defaultValues, multiple]);
const [selected, setSelected] =
React.useState<TWUISelectOptionObject<KeyType, T>[]>(initialSelected);
const [query, setQuery] = React.useState(
!multiple && initialSelected[0]
? getOptionLabel(initialSelected[0])
: "",
);
const [open, setOpen] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(0);
const [validity, setValidity] = React.useState<TWUISelectValidityObject>({
isValid: true,
});
const [focused, setFocused] = React.useState(false);
const selectedValues = React.useMemo(
() => new Set(selected.map((s) => s.value)),
[selected],
);
const filtered = React.useMemo(() => {
if (multiple) {
return options.filter((o) => matchesQuery(o, query));
}
const single = selected[0];
const isSearching =
!single ||
query.trim().toLowerCase() !== getOptionLabel(single).toLowerCase();
if (!isSearching) return options;
return options.filter((o) => matchesQuery(o, query));
}, [options, query, multiple, selected]);
const notify = React.useCallback(
(next: TWUISelectOptionObject<KeyType, T>[]) => {
if (multiple) {
const values = next.map((o) => o.value);
const data = next.map((o) => o.data).filter(Boolean) as T[];
changeHandlerRef.current?.(values, data);
dispatchStateRef.current?.(
(data.length ? data : undefined) as any,
);
if (selectRef.current) {
Array.from(selectRef.current.options).forEach((opt) => {
opt.selected = values.includes(opt.value as KeyType);
});
}
} else {
const option = next[0];
if (option) {
changeHandlerRef.current?.(option.value, option.data);
dispatchStateRef.current?.(option.data as any);
if (selectRef.current)
selectRef.current.value = option.value;
validateValueFn?.(option.value).then(setValidity);
} else {
changeHandlerRef.current?.("", undefined);
dispatchStateRef.current?.(undefined);
if (selectRef.current) selectRef.current.value = "";
setValidity({ isValid: true });
}
}
},
[multiple, selectRef, validateValueFn],
);
const commit = React.useCallback(
(option: TWUISelectOptionObject<KeyType, T>) => {
if (multiple) {
setSelected((prev) => {
const exists = prev.some((s) => s.value === option.value);
const next = exists
? prev.filter((s) => s.value !== option.value)
: [...prev, option];
notify(next);
return next;
});
setQuery("");
setOpen(true);
setActiveIndex(0);
inputRef.current?.focus();
} else {
const next = [option];
setSelected(next);
setQuery(getOptionLabel(option));
setOpen(true);
setActiveIndex(0);
notify(next);
}
},
[multiple, notify],
);
const removeOne = React.useCallback(
(value: KeyType) => {
setSelected((prev) => {
const next = prev.filter((s) => s.value !== value);
notify(next);
return next;
});
if (!multiple) setQuery("");
inputRef.current?.focus();
},
[multiple, notify],
);
const clear = React.useCallback(() => {
setSelected([]);
setQuery("");
setOpen(true);
setActiveIndex(0);
setValidity({ isValid: true });
notify([]);
inputRef.current?.focus();
}, [notify]);
const initRef = React.useRef(false);
React.useEffect(() => {
if (initRef.current || !initialSelected.length) return;
initRef.current = true;
notify(initialSelected);
}, [initialSelected, notify]);
React.useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) {
setOpen(false);
if (!multiple && selected[0]) {
setQuery(getOptionLabel(selected[0]));
} else if (multiple) {
setQuery("");
}
}
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, selected, multiple]);
React.useEffect(() => {
if (!open || !listRef.current) return;
listRef.current
.querySelector<HTMLElement>('[data-active="true"]')
?.scrollIntoView({ block: "nearest" });
}, [activeIndex, open]);
React.useEffect(() => {
setActiveIndex(0);
}, [query, open]);
const fieldId = React.useMemo(() => {
const base = label || props.title || props.name || "search-select";
return `twui-search-select-${twuiSlugify(String(base))}`;
}, [label, props.title, props.name]);
const displayLabel = label || props.title || props.name;
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
return;
}
setActiveIndex((i) =>
filtered.length ? Math.min(i + 1, filtered.length - 1) : 0,
);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
return;
}
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
if (open && filtered[activeIndex]) {
e.preventDefault();
commit(filtered[activeIndex]);
if (!multiple) inputRef.current?.blur();
}
} else if (e.key === "Backspace" && !query && selected.length) {
e.preventDefault();
const last = selected[selected.length - 1];
if (last) removeOne(last.value);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
if (!multiple && selected[0]) {
setQuery(getOptionLabel(selected[0]));
} else {
setQuery("");
}
inputRef.current?.blur();
} else if (e.key === "Tab") {
setOpen(false);
if (!multiple && selected[0]) {
setQuery(getOptionLabel(selected[0]));
} else {
setQuery("");
}
}
};
const {
className: inputClassName,
onChange: inputOnChange,
onFocus: inputOnFocus,
onBlur: inputOnBlur,
onKeyDown: inputOnKeyDown,
placeholder: inputPlaceholder,
} = input_props || {};
const {
className: wrapperClassName,
center: _center,
gap: _gap,
componentRef: _wwRef,
...restWrapperWrapperProps
} = (wrapperWrapperProps || {}) as Record<string, any>;
const showClear = selected.length > 0 || !!query;
const inputPlaceholderText =
selected.length && multiple
? ""
: inputPlaceholder ||
placeholder ||
props.title ||
"Search options";
return (
<div
ref={rootRef}
{...restWrapperWrapperProps}
className={twMerge(
"flex flex-col gap-1 w-full relative",
"twui-search-select-wrapper",
wrapperClassName,
)}
>
<select
ref={selectRef}
name={props.name}
multiple={multiple || undefined}
value={
multiple
? selected.map((s) => s.value)
: (selected[0]?.value ?? "")
}
required={props.required}
disabled={props.disabled}
tabIndex={-1}
aria-hidden
className="absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0"
style={{ clip: "rect(0, 0, 0, 0)" }}
onChange={() => {}}
>
{!multiple ? <option value="" /> : null}
{options.map((o) => (
<option key={o.value} value={o.value}>
{getOptionLabel(o)}
</option>
))}
</select>
<div
{...wrapperProps}
className={twMerge(
"relative flex items-center gap-2 w-full rounded-default px-3 py-2 outline-1",
"bg-white dark:bg-background-dark",
multiple && "flex-wrap min-h-[42px]",
focused && validity.isValid
? "outline-slate-700 dark:outline-white/50"
: "outline-slate-300 dark:outline-white/20",
!validity.isValid && "outline-orange-500",
props.disabled && "opacity-50 pointer-events-none",
"twui-input-wrapper",
wrapperProps?.className,
)}
onClick={() => {
inputRef.current?.focus();
setOpen(true);
}}
>
{showLabel && displayLabel ? (
<label
htmlFor={fieldId}
{...labelProps}
className={twMerge(
"text-xs absolute -top-2.5 left-2 z-20 px-1.5 rounded-t-default",
"text-foreground-light/80 bg-background-light",
"dark:text-foreground-dark/80 dark:bg-background-dark",
"whitespace-nowrap overflow-hidden text-ellipsis",
"twui-input-label",
labelProps?.className,
)}
>
{displayLabel}
{props.required ? (
<span className="text-secondary ml-1">*</span>
) : null}
</label>
) : null}
<Search
size={18}
className="shrink-0 opacity-70"
{...iconProps}
/>
{multiple
? selected.map((item) => (
<span
key={item.value}
className={twMerge(
"inline-flex items-center gap-1 shrink-0",
"max-w-full px-2 py-0.5 rounded-full text-xs",
"bg-gray/15 dark:bg-white/10",
"text-foreground-light dark:text-foreground-dark",
)}
>
<span className="truncate">
{getOptionLabel(item)}
</span>
<button
type="button"
tabIndex={-1}
aria-label={`Remove ${getOptionLabel(item)}`}
className="shrink-0 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/20 cursor-pointer"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeOne(item.value);
}}
>
<X size={12} strokeWidth={2.5} />
</button>
</span>
))
: null}
<input
id={fieldId}
ref={inputRef}
type="text"
role="combobox"
aria-expanded={open}
aria-controls={`${fieldId}-listbox`}
aria-autocomplete="list"
aria-multiselectable={multiple || undefined}
aria-activedescendant={
open && filtered[activeIndex]
? `${fieldId}-opt-${activeIndex}`
: undefined
}
autoComplete="off"
disabled={props.disabled}
placeholder={inputPlaceholderText}
value={query}
className={twMerge(
"grow min-w-[4rem] w-0 bg-transparent outline-none border-none p-0",
"text-foreground-light dark:text-foreground-dark",
"twui-input twui-search-select",
inputClassName,
props.className,
)}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
setActiveIndex(0);
inputOnChange?.(e as any);
}}
onFocus={(e) => {
setFocused(true);
setOpen(true);
inputOnFocus?.(e as any);
}}
onBlur={(e) => {
setFocused(false);
inputOnBlur?.(e as any);
}}
onKeyDown={(e) => {
onKeyDown(e);
inputOnKeyDown?.(e as any);
}}
onClick={(e) => e.stopPropagation()}
/>
{showClear ? (
<button
type="button"
tabIndex={-1}
aria-label="Clear selection"
title="Clear selection"
className={twMerge(
"shrink-0 flex items-center justify-center",
"w-6 h-6 rounded-full cursor-pointer",
"text-foreground-light dark:text-foreground-dark",
"opacity-70 hover:opacity-100",
"hover:bg-gray/20 dark:hover:bg-white/10",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clear();
}}
>
<X size={14} strokeWidth={2.5} />
</button>
) : null}
<button
type="button"
tabIndex={-1}
aria-label="Toggle options"
className={twMerge(
"shrink-0 flex items-center justify-center",
"w-6 h-6 cursor-pointer",
"text-foreground-light dark:text-foreground-dark",
"opacity-80 hover:opacity-100",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setOpen((v) => {
const next = !v;
if (next) inputRef.current?.focus();
else if (!multiple && selected[0]) {
setQuery(getOptionLabel(selected[0]));
} else if (multiple) {
setQuery("");
}
return next;
});
}}
>
<ChevronDown
size={18}
className={twMerge(
"transition-transform duration-150",
open && "rotate-180",
)}
/>
</button>
</div>
{open ? (
<ul
id={`${fieldId}-listbox`}
ref={listRef}
role="listbox"
aria-multiselectable={multiple || undefined}
className={twMerge(
"absolute left-0 right-0 top-full mt-1 z-100",
"max-h-[40vh] overflow-y-auto",
"rounded-default border border-solid",
"border-slate-200 dark:border-white/10",
"bg-background-light dark:bg-background-dark",
"shadow-lg shadow-slate-200/80 dark:shadow-black/40",
"p-0 m-0 list-none",
)}
>
{filtered.length === 0 ? (
<li className="px-3 py-2 text-sm opacity-60">
No options found
</li>
) : (
filtered.map((option, index) => {
const active = index === activeIndex;
const isSelected = selectedValues.has(option.value);
return (
<li
key={option.value}
id={`${fieldId}-opt-${index}`}
role="option"
aria-selected={isSelected}
data-active={active ? "true" : "false"}
className={twMerge(
"px-3 py-2 cursor-pointer text-sm flex items-center gap-2",
"text-foreground-light dark:text-foreground-dark",
"border-b border-slate-100 dark:border-white/5 last:border-b-0",
isSelected &&
!active &&
"bg-gray/10 dark:bg-white/5",
active &&
"bg-background-dark text-foreground-dark dark:bg-background-light dark:text-foreground-light twui-select-target-option",
option_button_props?.className,
)}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => commit(option)}
>
{multiple ? (
<span
className={twMerge(
"shrink-0 w-4 h-4 rounded border flex items-center justify-center",
isSelected
? "bg-primary border-primary text-white"
: "border-slate-300 dark:border-white/30",
)}
>
{isSelected ? (
<Check
size={12}
strokeWidth={3}
/>
) : null}
</span>
) : null}
<span className="truncate">
{getOptionLabel(option)}
</span>
</li>
);
})
)}
</ul>
) : null}
{!validity.isValid && validity.msg ? (
<Span size="smaller" className="text-warning">
{validity.msg}
</Span>
) : null}
{info ? (
<Span size="smaller" className="opacity-60">
{info}
</Span>
) : null}
</div>
);
}