combobox: use match sorter

This commit is contained in:
Elijah Duffy
2025-07-15 14:28:26 -07:00
parent c99b1a2899
commit e905eab6e7

View File

@@ -26,6 +26,7 @@
import { scale } from 'svelte/transition';
import { generateIdentifier } from './util';
import type { ClassValue } from 'svelte/elements';
import { matchSorter } from 'match-sorter';
interface Props {
name?: string;
@@ -38,11 +39,14 @@
invalidMessage?: string | null;
label?: string;
placeholder?: string;
loading?: boolean;
notFoundMessage?: string;
class?: ClassValue | null | undefined;
use?: () => void;
onvalidate?: (e: InputValidatorEvent) => void;
onchange?: (item: ComboboxOption) => void;
onsearch?: (query: string) => boolean | void;
onscroll?: (detail: { event: MouseEvent; top: boolean; false: boolean }) => void;
}
let {
@@ -56,11 +60,14 @@
invalidMessage = 'Please select an option',
label,
placeholder,
loading = false,
notFoundMessage = 'No results found',
class: classValue,
use,
onvalidate,
onchange
onchange,
onsearch,
onscroll
}: Props = $props();
let id = $derived(generateIdentifier('combobox', name));
@@ -74,32 +81,39 @@
let searchContainer = $state<HTMLDivElement | null>(null);
let pickerContainer = $state<HTMLDivElement | null>(null);
/** stores options filtered according to search value */
const filteredItems = $derived(
searchValue === ''
? options
: options.filter((item) => getLabel(item).toLowerCase().includes(searchValue.toLowerCase()))
: matchSorter(options, searchValue, { keys: [(item) => getLabel(item)] })
);
/** stores currently highlighted option (according to hover state or keyboard navigation) */
let highlighted = $derived.by((): ComboboxOption | undefined => {
if (filteredItems.length === 0) return undefined;
if (value !== undefined && filteredItems.find((v) => v.value === value?.value)) return value;
return filteredItems[0];
});
/** controls whether an icon should be displayed */
let iconVisible = $derived.by(() => {
return (open && highlighted && highlighted.icon) || (value && value.icon && searchValue === '');
});
/** controls whether the highlighted option should be used in the selection preview */
let useHighlighted = $derived.by(() => {
return open && highlighted;
});
const validateOpts: ValidatorOptions = { required };
/** returns the index of the highlighted item in filteredItems */
const getHightlightedID = () => {
if (highlighted === undefined) return -1;
return filteredItems.findIndex((item) => item.value === highlighted?.value);
};
/** action to set the minimum width of the combobox based on the number of options */
const minWidth: Action<HTMLDivElement, { options: ComboboxOption[] }> = (
container,
buildOpts
@@ -125,6 +139,7 @@
};
};
/** updates the position of the picker */
const updatePickerRect = async () => {
if (!searchContainer || !pickerContainer) {
await tick();
@@ -166,6 +181,7 @@
overlay.style.left = `${targetRect.left}px`;
};
/** scrolls the picker to the highlighted item */
const scrollToHighlighted = () => {
if (!pickerContainer || !highlighted) return;
const highlightedElement = pickerContainer.querySelector(`[data-id="${highlighted.value}"]`);
@@ -187,10 +203,12 @@
const conditionalUse = $derived(use ? use : () => {});
/** focuses the combobox search input */
export const focus = () => {
searchInput?.focus();
};
/** sets the value of the combobox by its underlying value field */
export const setValueByString = (searchVal: string) => {
const item = options.find((opt) => opt.value === searchVal);
if (item) {