combobox: add compact mode, open state callbacks

This commit is contained in:
Elijah Duffy
2025-07-15 15:24:09 -07:00
parent bb44a946e1
commit 1ae9b3a34f
2 changed files with 47 additions and 24 deletions

View File

@@ -48,6 +48,8 @@
loading?: boolean; loading?: boolean;
/** applies the loading state on first interaction */ /** applies the loading state on first interaction */
lazy?: boolean; lazy?: boolean;
/** uses a compact layout for the search input with less padding and smaller text */
compact?: boolean;
notFoundMessage?: string; notFoundMessage?: string;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
use?: () => void; use?: () => void;
@@ -55,6 +57,10 @@
onchange?: (item: ComboboxOption) => void; onchange?: (item: ComboboxOption) => void;
onsearch?: (query: string) => boolean | void; onsearch?: (query: string) => boolean | void;
onscroll?: (detail: { event: UIEvent; top: boolean; bottom: boolean }) => void; onscroll?: (detail: { event: UIEvent; top: boolean; bottom: boolean }) => void;
/** this callback runs only once on the first interaction if lazy is enabled */
onlazy?: () => void;
onopen?: () => void;
onclose?: () => void;
} }
let { let {
@@ -70,13 +76,17 @@
placeholder, placeholder,
loading = false, loading = false,
lazy = false, lazy = false,
compact = false,
notFoundMessage = 'No results found', notFoundMessage = 'No results found',
class: classValue, class: classValue,
use, use,
onvalidate, onvalidate,
onchange, onchange,
onsearch, onsearch,
onscroll onscroll,
onlazy,
onopen,
onclose
}: Props = $props(); }: Props = $props();
let id = $derived(generateIdentifier('combobox', name)); let id = $derived(generateIdentifier('combobox', name));
@@ -155,6 +165,32 @@
return getLogicalOption(getHighlightedIndex(), -1); return getLogicalOption(getHighlightedIndex(), -1);
}; };
let lazyApplied = false;
/** opens combobox picker and propages any necessary events */
const openPicker = () => {
open = true;
// if lazy and not applied, enable loading state once and run callback
if (lazy && !lazyApplied) {
lazyApplied = true;
loading = true;
onlazy?.();
}
updatePickerRect(); // update picker position
scrollToHighlighted(); // scroll to highlighted item
onopen?.(); // trigger onopen event if defined
};
/** closes combobox picker and propages any necessary events */
const closePicker = () => {
open = false;
searchValue = ''; // clear search value for next time picker opens
searching = false; // reset searching state
highlighted = value; // reset highlighted item to current value
onclose?.(); // trigger onclose event if defined
};
/** 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[] }> = (
container, container,
@@ -256,7 +292,7 @@
if (item) { if (item) {
value = item; value = item;
searchValue = ''; searchValue = '';
open = false; closePicker();
onchange?.(item); onchange?.(item);
} else { } else {
console.warn(`Combobox: No option found with value "${searchVal}"`); console.warn(`Combobox: No option found with value "${searchVal}"`);
@@ -280,25 +316,10 @@
return; return;
} }
open = false; closePicker();
}); });
} }
// 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 {
searchValue = '';
searching = false;
highlighted = value;
}
});
// when the value (or, in some circumstances, highlighted item) changes, update the search input // when the value (or, in some circumstances, highlighted item) changes, update the search input
$effect(() => { $effect(() => {
if (!searchInput) return; if (!searchInput) return;
@@ -349,7 +370,7 @@
role="listbox" role="listbox"
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
open = false; closePicker();
searchInput?.focus(); searchInput?.focus();
} }
}} }}
@@ -382,7 +403,7 @@
onclick={() => { onclick={() => {
if (item.disabled) return; if (item.disabled) return;
value = item; value = item;
open = false; closePicker();
searchInput?.focus(); searchInput?.focus();
onchange?.(item); onchange?.(item);
}} }}
@@ -482,6 +503,7 @@
<StyledRawInput <StyledRawInput
class={[iconVisible && 'pl-10', caret && 'pr-9', !valid && 'border-red-500!']} class={[iconVisible && 'pl-10', caret && 'pr-9', !valid && 'border-red-500!']}
{compact}
type="text" type="text"
name={name + '_search'} name={name + '_search'}
{placeholder} {placeholder}
@@ -494,7 +516,7 @@
}, 100); }, 100);
} }
open = true; openPicker();
}} }}
onkeydown={(e) => { onkeydown={(e) => {
if (!searchInput) return; if (!searchInput) return;
@@ -508,15 +530,15 @@
e.preventDefault(); e.preventDefault();
} }
open = false; closePicker();
return; return;
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
open = false; closePicker();
return; return;
} }
// open the picker // open the picker
open = true; openPicker();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
searching = false; searching = false;

View File

@@ -148,6 +148,7 @@
/> />
<Combobox label="Lazy combobox" placeholder="Choose..." options={[]} lazy /> <Combobox label="Lazy combobox" placeholder="Choose..." options={[]} lazy />
<Combobox label="Compact combobox" placeholder="Choose..." options={[]} lazy compact />
</InputGroup> </InputGroup>
</div> </div>