combobox: implement loading state w/ new listeners & lazy mode

This commit is contained in:
Elijah Duffy
2025-07-15 15:07:14 -07:00
parent e905eab6e7
commit bb44a946e1
2 changed files with 112 additions and 35 deletions

View File

@@ -27,6 +27,7 @@
import { generateIdentifier } from './util'; import { generateIdentifier } from './util';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import { matchSorter } from 'match-sorter'; import { matchSorter } from 'match-sorter';
import Spinner from './Spinner.svelte';
interface Props { interface Props {
name?: string; name?: string;
@@ -39,14 +40,21 @@
invalidMessage?: string | null; invalidMessage?: string | null;
label?: string; label?: string;
placeholder?: 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; loading?: boolean;
/** applies the loading state on first interaction */
lazy?: boolean;
notFoundMessage?: string; notFoundMessage?: string;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
use?: () => void; use?: () => void;
onvalidate?: (e: InputValidatorEvent) => void; onvalidate?: (e: InputValidatorEvent) => void;
onchange?: (item: ComboboxOption) => void; onchange?: (item: ComboboxOption) => void;
onsearch?: (query: string) => boolean | 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 { let {
@@ -61,6 +69,7 @@
label, label,
placeholder, placeholder,
loading = false, loading = false,
lazy = false,
notFoundMessage = 'No results found', notFoundMessage = 'No results found',
class: classValue, class: classValue,
use, use,
@@ -82,22 +91,28 @@
let pickerContainer = $state<HTMLDivElement | null>(null); let pickerContainer = $state<HTMLDivElement | null>(null);
/** stores options filtered according to search value */ /** stores options filtered according to search value */
const filteredItems = $derived( const filteredItems = $derived.by(() => {
searchValue === '' const arr = matchSorter(options, searchValue, { keys: [(item) => getLabel(item)] });
? options // if (loading) {
: matchSorter(options, searchValue, { keys: [(item) => getLabel(item)] }) // 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 => { let highlighted = $derived.by((): ComboboxOption | undefined => {
if (filteredItems.length === 0) return undefined; if (filteredItems.length === 0) return undefined;
if (value !== undefined && filteredItems.find((v) => v.value === value?.value)) return value; 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 */ /** controls whether an icon should be displayed */
let iconVisible = $derived.by(() => { 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 */ /** controls whether the highlighted option should be used in the selection preview */
@@ -108,10 +123,37 @@
const validateOpts: ValidatorOptions = { required }; const validateOpts: ValidatorOptions = { required };
/** returns the index of the highlighted item in filteredItems */ /** returns the index of the highlighted item in filteredItems */
const getHightlightedID = () => { const getHighlightedIndex = () => {
if (highlighted === undefined) return -1; if (highlighted === undefined) return -1;
return filteredItems.findIndex((item) => item.value === highlighted?.value); 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 */ /** action to set the minimum width of the combobox based on the number of options */
const minWidth: Action<HTMLDivElement, { options: ComboboxOption[] }> = ( const minWidth: Action<HTMLDivElement, { options: ComboboxOption[] }> = (
@@ -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, 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) // when the picker is closed, reset highlighted to the currently selected value (if any)
$effect(() => { $effect(() => {
if (open) { if (open) {
if (lazy) loading = true;
updatePickerRect(); updatePickerRect();
scrollToHighlighted(); scrollToHighlighted();
} else { } else {
@@ -288,6 +331,7 @@
}); });
</script> </script>
<!-- Combobox picker -->
<Portal target="body"> <Portal target="body">
{#if open} {#if open}
<div <div
@@ -309,6 +353,16 @@
searchInput?.focus(); searchInput?.focus();
} }
}} }}
onscroll={(e) => {
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" tabindex="0"
> >
{#each filteredItems as item, i (i + item.value)} {#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', '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', 'rounded-sm text-sm capitalize outline-hidden select-none',
'hover:bg-sui-accent-500/30 dark:hover:bg-sui-accent-700/30', '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" role="option"
onclick={() => { onclick={() => {
if (item.disabled) return;
value = item; value = item;
open = false; open = false;
searchInput?.focus(); searchInput?.focus();
@@ -358,7 +414,13 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<span class="block px-5 py-2 text-sm">{notFoundMessage}</span> <span class="block px-5 py-2 text-sm">
{#if loading}
Loading...
{:else}
{notFoundMessage}
{/if}
</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -396,6 +458,7 @@
{/if} {/if}
</div> </div>
<!-- Search input box -->
{#snippet searchInputBox(caret: boolean = true)} {#snippet searchInputBox(caret: boolean = true)}
<div class="relative"> <div class="relative">
{#if iconVisible} {#if iconVisible}
@@ -405,7 +468,9 @@
]} ]}
transition:scale transition:scale
> >
{#if useHighlighted && highlighted?.icon} {#if loading}
<Spinner class="stroke-sui-accent!" size="1em" />
{:else if useHighlighted && highlighted?.icon}
{@render highlighted.icon(highlighted)} {@render highlighted.icon(highlighted)}
{:else if value?.icon} {:else if value?.icon}
{@render value.icon(value)} {@render value.icon(value)}
@@ -435,7 +500,7 @@
if (!searchInput) return; if (!searchInput) return;
if (e.key === 'Tab' || e.key === 'Enter') { 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; value = highlighted;
onchange?.(highlighted); onchange?.(highlighted);
} }
@@ -460,16 +525,10 @@
} }
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
const nextIndex = getHightlightedID() + 1; highlighted = getNextOption();
if (nextIndex < filteredItems.length) {
highlighted = filteredItems[nextIndex];
}
return; return;
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
const prevIndex = getHightlightedID() - 1; highlighted = getPrevOption();
if (prevIndex >= 0) {
highlighted = filteredItems[prevIndex];
}
return; return;
} }
}} }}
@@ -477,6 +536,7 @@
if (!searchInput) return; if (!searchInput) return;
searchValue = searchInput.value; searchValue = searchInput.value;
searching = true; searching = true;
onsearch?.(searchValue);
}} }}
use={() => { use={() => {
conditionalUse(); conditionalUse();

View File

@@ -120,18 +120,35 @@
<div class="component"> <div class="component">
<p class="title">Combobox</p> <p class="title">Combobox</p>
<Combobox <InputGroup>
name="example-combobox" <Combobox
label="Select an option" name="example-combobox"
placeholder="Choose..." label="Select an option"
options={[ placeholder="Choose..."
{ value: 'option1', label: 'Option 1' }, options={[
{ value: 'option2', label: 'Option 2' }, { value: 'option1', label: 'Option 1' },
{ value: 'option3', label: 'Option 3' } { value: 'option2', label: 'Option 2' },
]} { value: 'option3', label: 'Option 3', disabled: true }
onchange={(e) => console.log('Selected:', e.value)} ]}
onvalidate={(e) => console.log('Validation:', e.detail)} onchange={(e) => console.log('Selected:', e.value)}
/> onvalidate={(e) => console.log('Validation:', e.detail)}
/>
<Combobox
loading
label="Loading state combobox"
placeholder="Choose..."
options={[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' }
]}
onchange={(e) => console.log('Selected:', e.value)}
onvalidate={(e) => console.log('Validation:', e.detail)}
/>
<Combobox label="Lazy combobox" placeholder="Choose..." options={[]} lazy />
</InputGroup>
</div> </div>
<div class="component"> <div class="component">