@ -37,20 +37,14 @@
let stageRef = $state< HTMLElement | null > (null);
let stageRef = $state< HTMLElement | null > (null);
let resultsOpen = $state(false);
let resultsOpen = $state(false);
let inputRef = $state< HTMLInputElement | null > (null);
let inputRef = $state< HTMLInputElement | null > (null);
// Index of the visually-highlighted result. Drives `aria-activedescendant`
// on the input (the input keeps DOM focus per WAI-ARIA combobox guidance).
// `-1` means no item is highlighted.
let highlightedIndex = $state(-1);
// Set briefly after committing a pick so the input refocus doesn't
// Set briefly after committing a pick so the input refocus doesn't
// re-open the popover we just closed via `onfocusin` on the anchor.
// re-open the popover we just closed via `onfocusin` on the anchor.
let justCommitted = false;
let justCommitted = false;
function commitPick(name: string) {
value = name;
resultsOpen = false;
justCommitted = true;
inputRef?.focus();
queueMicrotask(() => {
justCommitted = false;
});
}
const compiled = compileMorfo(searchFieldMorfo);
const compiled = compileMorfo(searchFieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const events = $derived([...compiled.actions.byName.values()]);
@ -102,6 +96,80 @@
(c) => c.name.toLowerCase().includes(q) || c.group.toLowerCase().includes(q)
(c) => c.name.toLowerCase().includes(q) || c.group.toLowerCase().includes(q)
);
);
});
});
const highlightedId = $derived(
highlightedIndex >= 0 & & highlightedIndex < results.length
? `search-field-demo-result-${ results [ highlightedIndex ]. name . replace ( /\s+/g , '-' )} `
: undefined
);
function openResults() {
if (disabled || readonly || justCommitted) return;
resultsOpen = true;
}
function commitPick(name: string) {
value = name;
resultsOpen = false;
highlightedIndex = -1;
justCommitted = true;
inputRef?.focus();
queueMicrotask(() => {
justCommitted = false;
});
}
function handleInputKeydown(e: KeyboardEvent) {
if (disabled || readonly) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!resultsOpen) openResults();
highlightedIndex = results.length === 0 ? -1 : (highlightedIndex + 1) % results.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (!resultsOpen) openResults();
highlightedIndex =
results.length === 0
? -1
: highlightedIndex < = 0
? results.length - 1
: highlightedIndex - 1;
} else if (e.key === 'Home' && resultsOpen) {
e.preventDefault();
highlightedIndex = results.length === 0 ? -1 : 0;
} else if (e.key === 'End' && resultsOpen) {
e.preventDefault();
highlightedIndex = results.length === 0 ? -1 : results.length - 1;
} else if (e.key === 'Enter' && resultsOpen && highlightedIndex >= 0) {
const target = results[highlightedIndex];
if (target) {
e.preventDefault();
commitPick(target.name);
}
} else if (e.key === 'Escape' && resultsOpen) {
// Soma's clearOnEscape may also fire — closing the popover first
// keeps the two paths independent.
e.stopPropagation();
resultsOpen = false;
highlightedIndex = -1;
}
}
// Reset highlight whenever the result set shrinks past it (e.g. user
// types and the previously-highlighted item falls out of the list).
$effect(() => {
if (highlightedIndex >= results.length) highlightedIndex = -1;
});
// Auto-scroll the highlighted item into view inside the popover.
$effect(() => {
if (!resultsOpen || !highlightedId) return;
// Defer to next frame so the option exists in the DOM.
requestAnimationFrame(() => {
const el = document.getElementById(highlightedId);
el?.scrollIntoView({ block : 'nearest' } );
});
});
const variants: SearchFieldVariant[] = ['surface', 'outline', 'ghost'];
const variants: SearchFieldVariant[] = ['surface', 'outline', 'ghost'];
const colors: SearchFieldColor[] = [
const colors: SearchFieldColor[] = [
'primary',
'primary',
@ -194,7 +262,7 @@
>< span data-uix-meta-key > parts</ span > { compiled . parts . order . length } < /span
>< span data-uix-meta-key > parts</ span > { compiled . parts . order . length } < /span
>
>
< span data-uix-meta-pill >< span data-uix-meta-key > events</ span > { events . length } </ span >
< span data-uix-meta-pill >< span data-uix-meta-key > events</ span > { events . length } </ span >
< span data-uix-meta-pill > < span data-uix-meta-key > keyboard< / span > 2 < / span >
< span data-uix-meta-pill > < span data-uix-meta-key > keyboard< / span > 7 < / span >
< span data-uix-meta-pill > < span data-uix-meta-key > sizes< / span > 3< / span >
< span data-uix-meta-pill > < span data-uix-meta-key > sizes< / span > 3< / span >
< span data-uix-meta-pill > < span data-uix-meta-key > variants< / span > 3< / span >
< span data-uix-meta-pill > < span data-uix-meta-key > variants< / span > 3< / span >
< / div >
< / div >
@ -204,11 +272,7 @@
< div data-uix-stage-area bind:this = { stageRef } >
< div data-uix-stage-area bind:this = { stageRef } >
< div style = "inline-size: min(26rem, 100%);" >
< div style = "inline-size: min(26rem, 100%);" >
< Popover bind:open = { resultsOpen } >
< Popover bind:open = { resultsOpen } >
< Popover.Anchor
< Popover.Anchor onfocusin = { openResults } >
onfocusin={() => {
if (!disabled & & !readonly & & !justCommitted) resultsOpen = true;
}}
>
< SearchField
< SearchField
bind:value
bind:value
{ placeholder }
{ placeholder }
@ -225,7 +289,8 @@
onValueChange={( next ) => {
onValueChange={( next ) => {
lastValueChange = next === '' ? 'empty' : next;
lastValueChange = next === '' ? 'empty' : next;
lastValueChangeAt = Date.now();
lastValueChangeAt = Date.now();
if (!disabled & & !readonly) resultsOpen = true;
openResults();
highlightedIndex = -1;
}}
}}
onSubmit={( next ) => {
onSubmit={( next ) => {
lastSubmit = next || 'empty';
lastSubmit = next || 'empty';
@ -234,12 +299,20 @@
onClear={() => {
onClear={() => {
lastSubmit = 'cleared';
lastSubmit = 'cleared';
resultsOpen = false;
resultsOpen = false;
highlightedIndex = -1;
}}
}}
>
>
< SearchField.Icon >
< SearchField.Icon >
< Search size = "1em" decorative / >
< Search size = "1em" decorative / >
< / SearchField.Icon >
< / SearchField.Icon >
< SearchField.Input bind:ref = { inputRef } / >
< SearchField.Input
bind:ref={ inputRef }
aria-controls="search-field-demo-listbox"
aria-expanded={ resultsOpen }
aria-activedescendant={ highlightedId }
aria-autocomplete="list"
onkeydown={ handleInputKeydown }
/>
< SearchField.LoadingIndicator / >
< SearchField.LoadingIndicator / >
< SearchField.ClearTrigger
< SearchField.ClearTrigger
aria-label={ uix . langs . ts ( '#?components.search-field.clear|Clear' )}
aria-label={ uix . langs . ts ( '#?components.search-field.clear|Clear' )}
@ -258,21 +331,29 @@
< PickerShell.Body >
< PickerShell.Body >
<!-- Live result list — filters the demo dataset by name/group. -->
<!-- Live result list — filters the demo dataset by name/group. -->
< ul
< ul
id="search-field-demo-listbox"
class="search-field-results"
class="search-field-results"
role="listbox"
role="listbox"
aria-label="Filtered components"
aria-label="Filtered components"
aria-live="polite"
>
>
{ #if results . length === 0 }
{ #if results . length === 0 }
< li class = "search-field-results-empty" >
< li class = "search-field-results-empty" >
No matches for < strong > "{ value } "</ strong >
No matches for < strong > "{ value } "</ strong >
< / li >
< / li >
{ : else }
{ : else }
{ #each results as item ( item . name )}
{ #each results as item , i ( item . name )}
< li role = "option" aria-selected = { value === item . name } >
{ @const itemId = `search-field-demo-result-$ { item . name . replace ( /\s+/g , '-' )} ` }
< li
id={ itemId }
role="option"
aria-selected={ value === item . name }
data-highlighted={ highlightedIndex === i ? '' : undefined }
>
< button
< button
type="button"
type="button"
tabindex="-1"
class="search-field-result"
class="search-field-result"
onpointermove={() => ( highlightedIndex = i )}
onclick={() => commitPick ( item . name )}
onclick={() => commitPick ( item . name )}
>
>
< span class = "search-field-result-name" > { item . name } </ span >
< span class = "search-field-result-name" > { item . name } </ span >
@ -710,7 +791,8 @@
cursor: pointer;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-default);
transition: background var(--duration-fast) var(--ease-default);
}
}
.search-field-result:hover {
.search-field-result:hover,
[role='option'][data-highlighted] .search-field-result {
background: var(--color-surface-overlay);
background: var(--color-surface-overlay);
}
}
.search-field-result:focus-visible {
.search-field-result:focus-visible {