combobox: implement loading state w/ new listeners & lazy mode
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -120,10 +120,24 @@
|
|||||||
<div class="component">
|
<div class="component">
|
||||||
<p class="title">Combobox</p>
|
<p class="title">Combobox</p>
|
||||||
|
|
||||||
|
<InputGroup>
|
||||||
<Combobox
|
<Combobox
|
||||||
name="example-combobox"
|
name="example-combobox"
|
||||||
label="Select an option"
|
label="Select an option"
|
||||||
placeholder="Choose..."
|
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={[
|
options={[
|
||||||
{ value: 'option1', label: 'Option 1' },
|
{ value: 'option1', label: 'Option 1' },
|
||||||
{ value: 'option2', label: 'Option 2' },
|
{ value: 'option2', label: 'Option 2' },
|
||||||
@@ -132,6 +146,9 @@
|
|||||||
onchange={(e) => console.log('Selected:', e.value)}
|
onchange={(e) => console.log('Selected:', e.value)}
|
||||||
onvalidate={(e) => console.log('Validation:', e.detail)}
|
onvalidate={(e) => console.log('Validation:', e.detail)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Combobox label="Lazy combobox" placeholder="Choose..." options={[]} lazy />
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="component">
|
<div class="component">
|
||||||
|
|||||||
Reference in New Issue
Block a user