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( option: TWUISelectOptionObject, ) { return option.title || option.value; } function matchesQuery( option: TWUISelectOptionObject, 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, "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) { const rootRef = React.useRef(null); const inputRef = React.useRef(null); const listRef = React.useRef(null); const internalSelectRef = React.useRef(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[]>(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({ 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[]) => { 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) => { 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('[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) => { 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; const showClear = selected.length > 0 || !!query; const inputPlaceholderText = selected.length && multiple ? "" : inputPlaceholder || placeholder || props.title || "Search options"; return (
{ inputRef.current?.focus(); setOpen(true); }} > {showLabel && displayLabel ? ( ) : null} {multiple ? selected.map((item) => ( {getOptionLabel(item)} )) : null} { 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 ? ( ) : null}
{open ? (
    {filtered.length === 0 ? (
  • No options found
  • ) : ( filtered.map((option, index) => { const active = index === activeIndex; const isSelected = selectedValues.has(option.value); return (
  • setActiveIndex(index)} onMouseDown={(e) => e.preventDefault()} onClick={() => commit(option)} > {multiple ? ( {isSelected ? ( ) : null} ) : null} {getOptionLabel(option)}
  • ); }) )}
) : null} {!validity.isValid && validity.msg ? ( {validity.msg} ) : null} {info ? ( {info} ) : null}
); }