From e905eab6e7208a136dcc6be9d405398fc17ed250 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 15 Jul 2025 14:28:26 -0700 Subject: [PATCH] combobox: use match sorter --- src/lib/Combobox.svelte | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/lib/Combobox.svelte b/src/lib/Combobox.svelte index c1f940b..4a3d714 100644 --- a/src/lib/Combobox.svelte +++ b/src/lib/Combobox.svelte @@ -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(null); let pickerContainer = $state(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 = ( 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) {