From 26daa9e22eb356dbe9cb8556bac1ac2bc522d692 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 22 May 2026 13:44:39 +0200 Subject: [PATCH] feat(search-field demo): full keyboard nav for results popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` and ids + data-highlighted on each option. Header meta pill updated to 7 keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../uix/components/search-field/+page.svelte | 126 +++++++++++++++--- 1 file changed, 104 insertions(+), 22 deletions(-) diff --git a/web/routes/uix/components/search-field/+page.svelte b/web/routes/uix/components/search-field/+page.svelte index 7cb8a4e62..8ae61b929 100644 --- a/web/routes/uix/components/search-field/+page.svelte +++ b/web/routes/uix/components/search-field/+page.svelte @@ -37,20 +37,14 @@ let stageRef = $state(null); let resultsOpen = $state(false); let inputRef = $state(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 // re-open the popover we just closed via `onfocusin` on the anchor. let justCommitted = false; - function commitPick(name: string) { - value = name; - resultsOpen = false; - justCommitted = true; - inputRef?.focus(); - queueMicrotask(() => { - justCommitted = false; - }); - } - const compiled = compileMorfo(searchFieldMorfo); const partsList = $derived([...compiled.parts.byKebab.values()]); const events = $derived([...compiled.actions.byName.values()]); @@ -102,6 +96,80 @@ (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 colors: SearchFieldColor[] = [ 'primary', @@ -194,7 +262,7 @@ >parts{compiled.parts.order.length} events{events.length} - keyboard2 + keyboard7 sizes3 variants3 @@ -204,11 +272,7 @@
- { - if (!disabled && !readonly && !justCommitted) resultsOpen = true; - }} - > + { lastValueChange = next === '' ? 'empty' : next; lastValueChangeAt = Date.now(); - if (!disabled && !readonly) resultsOpen = true; + openResults(); + highlightedIndex = -1; }} onSubmit={(next) => { lastSubmit = next || 'empty'; @@ -234,12 +299,20 @@ onClear={() => { lastSubmit = 'cleared'; resultsOpen = false; + highlightedIndex = -1; }} > - +
    {#if results.length === 0}
  • No matches for "{value}"
  • {:else} - {#each results as item (item.name)} -
  • + {#each results as item, i (item.name)} + {@const itemId = `search-field-demo-result-${item.name.replace(/\s+/g, '-')}`} +