diff --git a/src/lib/Combobox.svelte b/src/lib/Combobox.svelte index 4a3d714..118008c 100644 --- a/src/lib/Combobox.svelte +++ b/src/lib/Combobox.svelte @@ -27,6 +27,7 @@ import { generateIdentifier } from './util'; import type { ClassValue } from 'svelte/elements'; import { matchSorter } from 'match-sorter'; + import Spinner from './Spinner.svelte'; interface Props { name?: string; @@ -39,14 +40,21 @@ invalidMessage?: string | null; label?: string; placeholder?: string; + /** + * enables loading spinner and fallback text if no items are found. may be changed + * internally if lazy is enabled. loading is, however, not bindable, so don't expect + * any internal changes to be propagated outward. + */ loading?: boolean; + /** applies the loading state on first interaction */ + lazy?: 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; + onscroll?: (detail: { event: UIEvent; top: boolean; bottom: boolean }) => void; } let { @@ -61,6 +69,7 @@ label, placeholder, loading = false, + lazy = false, notFoundMessage = 'No results found', class: classValue, use, @@ -82,22 +91,28 @@ let pickerContainer = $state(null); /** stores options filtered according to search value */ - const filteredItems = $derived( - searchValue === '' - ? options - : matchSorter(options, searchValue, { keys: [(item) => getLabel(item)] }) - ); + const filteredItems = $derived.by(() => { + const arr = matchSorter(options, searchValue, { keys: [(item) => getLabel(item)] }); + // if (loading) { + // arr.push({ value: 'loading', label: 'Loading more...', disabled: true }); + // } + return arr; + }); - /** stores currently highlighted option (according to hover state or keyboard navigation) */ + /** stores currently highlighted option (according to keyboard navigation or default item) */ 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]; + if (!filteredItems[0]?.disabled) return filteredItems[0]; }); /** controls whether an icon should be displayed */ let iconVisible = $derived.by(() => { - return (open && highlighted && highlighted.icon) || (value && value.icon && searchValue === ''); + return ( + (open && highlighted && highlighted.icon) || + (value && value.icon && searchValue === '') || + loading + ); }); /** controls whether the highlighted option should be used in the selection preview */ @@ -108,10 +123,37 @@ const validateOpts: ValidatorOptions = { required }; /** returns the index of the highlighted item in filteredItems */ - const getHightlightedID = () => { + const getHighlightedIndex = () => { if (highlighted === undefined) return -1; return filteredItems.findIndex((item) => item.value === highlighted?.value); }; + /** returns the next or previous available item in filteredItems */ + const getLogicalOption = (startIndex: number, step: number): ComboboxOption | undefined => { + const constrain = (index: number) => { + if (index < 0) index = filteredItems.length - 1; + else if (index >= filteredItems.length) index = 0; + return index; + }; + + let index = constrain(startIndex + step); + while (index >= 0 && index < filteredItems.length) { + const option = filteredItems[index]; + if (option.disabled) { + index = constrain(index + step); + continue; + } + return option; + } + return undefined; + }; + /** returns the next available item in filteredItems */ + const getNextOption = () => { + return getLogicalOption(getHighlightedIndex(), 1); + }; + /** returns the previous available item in filteredItems */ + const getPrevOption = () => { + return getLogicalOption(getHighlightedIndex(), -1); + }; /** action to set the minimum width of the combobox based on the number of options */ const minWidth: Action = ( @@ -242,11 +284,12 @@ }); } - // when the picker is opened, update its position + // when the picker is opened, update its position and check if lazy // when the picker is closed, clear any search value (next time it opens, all options will be shown) // when the picker is closed, reset highlighted to the currently selected value (if any) $effect(() => { if (open) { + if (lazy) loading = true; updatePickerRect(); scrollToHighlighted(); } else { @@ -288,6 +331,7 @@ }); + {#if open}
{ + if (!onscroll) return; + const target = e.target as HTMLDivElement; + if (!target) return; + + const margin = 10; // 10px margin for top & bottom + const atTop = target.scrollTop < margin; + const atBottom = target.scrollTop + target.clientHeight > target.scrollHeight - margin; + onscroll({ event: e, top: atTop, bottom: atBottom }); + }} tabindex="0" > {#each filteredItems as item, i (i + item.value)} @@ -321,10 +375,12 @@ 'picker-item options-center mb-0.5 flex min-h-10 flex-wrap py-2.5 pr-1.5 pl-5', 'rounded-sm text-sm capitalize outline-hidden select-none', 'hover:bg-sui-accent-500/30 dark:hover:bg-sui-accent-700/30', - item.value === highlighted?.value && 'bg-sui-accent-500/80 dark:bg-sui-accent-700/80' + item.value === highlighted?.value && 'bg-sui-accent-500/80 dark:bg-sui-accent-700/80', + item.disabled && 'cursor-not-allowed opacity-50' ]} role="option" onclick={() => { + if (item.disabled) return; value = item; open = false; searchInput?.focus(); @@ -358,7 +414,13 @@ {/if}
{:else} - {notFoundMessage} + + {#if loading} + Loading... + {:else} + {notFoundMessage} + {/if} + {/each} {/if} @@ -396,6 +458,7 @@ {/if} + {#snippet searchInputBox(caret: boolean = true)}
{#if iconVisible} @@ -405,7 +468,9 @@ ]} transition:scale > - {#if useHighlighted && highlighted?.icon} + {#if loading} + + {:else if useHighlighted && highlighted?.icon} {@render highlighted.icon(highlighted)} {:else if value?.icon} {@render value.icon(value)} @@ -435,7 +500,7 @@ if (!searchInput) return; if (e.key === 'Tab' || e.key === 'Enter') { - if (open && highlighted && highlighted.value !== value?.value) { + if (open && highlighted && !highlighted.disabled && highlighted.value !== value?.value) { value = highlighted; onchange?.(highlighted); } @@ -460,16 +525,10 @@ } if (e.key === 'ArrowDown') { - const nextIndex = getHightlightedID() + 1; - if (nextIndex < filteredItems.length) { - highlighted = filteredItems[nextIndex]; - } + highlighted = getNextOption(); return; } else if (e.key === 'ArrowUp') { - const prevIndex = getHightlightedID() - 1; - if (prevIndex >= 0) { - highlighted = filteredItems[prevIndex]; - } + highlighted = getPrevOption(); return; } }} @@ -477,6 +536,7 @@ if (!searchInput) return; searchValue = searchInput.value; searching = true; + onsearch?.(searchValue); }} use={() => { conditionalUse(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 054cfe1..e1fdd69 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -120,18 +120,35 @@

Combobox

- console.log('Selected:', e.value)} - onvalidate={(e) => console.log('Validation:', e.detail)} - /> + + console.log('Selected:', e.value)} + onvalidate={(e) => console.log('Validation:', e.detail)} + /> + + console.log('Selected:', e.value)} + onvalidate={(e) => console.log('Validation:', e.detail)} + /> + + +