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 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<HTMLDivElement | null>(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<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, reset highlighted to the currently selected value (if any)
$effect(() => {
if (open) {
if (lazy) loading = true;
updatePickerRect();
scrollToHighlighted();
} else {
@@ -288,6 +331,7 @@
});
</script>
<!-- Combobox picker -->
<Portal target="body">
{#if open}
<div
@@ -309,6 +353,16 @@
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"
>
{#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}
</div>
{: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}
</div>
{/if}
@@ -396,6 +458,7 @@
{/if}
</div>
<!-- Search input box -->
{#snippet searchInputBox(caret: boolean = true)}
<div class="relative">
{#if iconVisible}
@@ -405,7 +468,9 @@
]}
transition:scale
>
{#if useHighlighted && highlighted?.icon}
{#if loading}
<Spinner class="stroke-sui-accent!" size="1em" />
{: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();

View File

@@ -120,10 +120,24 @@
<div class="component">
<p class="title">Combobox</p>
<InputGroup>
<Combobox
name="example-combobox"
label="Select an option"
placeholder="Choose..."
options={[
{ value: 'option1', label: 'Option 1' },
{ 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)}
/>
<Combobox
loading
label="Loading state combobox"
placeholder="Choose..."
options={[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
@@ -132,6 +146,9 @@
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 class="component">