621 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|