combobox: implement loading state w/ new listeners & lazy mode
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user