feat(search-field demo): full keyboard nav for results popover

Follows the WAI-ARIA combobox pattern via aria-activedescendant — the
input keeps DOM focus, highlight moves virtually:

- ArrowDown / ArrowUp: open popover if closed, cycle highlight
  (wrapping at edges).
- Home / End: jump to first / last (when open).
- Enter: commit the highlighted item, close popover.
- Escape: close popover without clearing value; Soma's clearOnEscape
  fires only when popover is already closed.
- pointermove on a result moves the highlight to it (mouse mirrors
  keyboard).
- highlighted item auto-scrolls into view; index resets when result
  set shrinks past it or after commit/clear.

Wires aria-controls / aria-expanded / aria-activedescendant /
aria-autocomplete on `<SearchField.Input>` and ids + data-highlighted
on each option. Header meta pill updated to 7 keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 99a1f05cd7
commit 26daa9e22e

@ -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 {

Loading…
Cancel
Save

Powered by TurnKey Linux.