fix(picker): honest combobox — keyboard navigation, listbox roles, no-results state
The input declared role=combobox + aria-autocomplete=list while arrow keys did nothing (WCAG 4.1.2). Wired the useTypeahead activeIndex the same way PersonTypeahead does: ArrowUp/Down cycle, Enter/Space select, Escape closes, aria-activedescendant tracks the active option; the list is a real listbox with option roles again (the interim role downgrade is reverted). Zero hits now render a 'Keine Treffer' row instead of silently vanishing, aria-expanded matches the visible state, and the hook sets loading at setQuery so the debounce window can't read as 'no results'. DocumentMultiSelect renders the shared error state too. _uid counter replaced with $props.id(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
<script module>
|
||||
let _uid = 0;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
@@ -23,15 +19,23 @@ let {
|
||||
onSelect
|
||||
}: Props = $props();
|
||||
|
||||
const listboxId = `doc-picker-listbox-${++_uid}`;
|
||||
const uid = $props.id();
|
||||
const listboxId = `doc-picker-listbox-${uid}`;
|
||||
|
||||
const picker = createDocumentTypeahead();
|
||||
|
||||
let inputValue = $state('');
|
||||
|
||||
const activeOptionId = $derived(
|
||||
picker.isOpen && picker.activeIndex >= 0 && picker.results[picker.activeIndex]
|
||||
? `${listboxId}-option-${picker.activeIndex}`
|
||||
: undefined
|
||||
);
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const q = (e.currentTarget as HTMLInputElement).value;
|
||||
inputValue = q;
|
||||
picker.setActiveIndex(-1);
|
||||
if (q.trim().length >= 1) {
|
||||
picker.setQuery(q);
|
||||
} else {
|
||||
@@ -45,6 +49,39 @@ function handleSelect(doc: DocumentOption) {
|
||||
picker.close();
|
||||
onSelect(doc);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!picker.isOpen) return;
|
||||
|
||||
const results = picker.results;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (results.length > 0) {
|
||||
picker.setActiveIndex((picker.activeIndex + 1) % results.length);
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (results.length > 0) {
|
||||
picker.setActiveIndex((picker.activeIndex - 1 + results.length) % results.length);
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const active = results[picker.activeIndex];
|
||||
// handleSelect is a no-op for already-added (aria-disabled) options.
|
||||
if (active) handleSelect(active);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
picker.close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOptionKeydown(e: KeyboardEvent, doc: DocumentOption) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect(doc);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
|
||||
@@ -53,34 +90,43 @@ function handleSelect(doc: DocumentOption) {
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
aria-label={placeholder}
|
||||
aria-expanded={picker.isOpen && picker.results.length > 0}
|
||||
aria-expanded={picker.isOpen}
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeOptionId}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
|
||||
{#if picker.isOpen && (picker.results.length > 0 || picker.loading || picker.error)}
|
||||
{#if picker.isOpen}
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||
>
|
||||
{#if picker.loading}
|
||||
<li class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</li>
|
||||
{:else if picker.error}
|
||||
<li role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</li>
|
||||
{:else if picker.results.length === 0}
|
||||
<li class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</li>
|
||||
{:else}
|
||||
{#each picker.results as doc (doc.id)}
|
||||
{#each picker.results as doc, i (doc.id)}
|
||||
{@const disabled = alreadyAddedIds.has(doc.id!)}
|
||||
<li
|
||||
id={`${listboxId}-option-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === picker.activeIndex}
|
||||
aria-disabled={disabled}
|
||||
onclick={() => handleSelect(doc)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSelect(doc)}
|
||||
onkeydown={(e) => handleOptionKeydown(e, doc)}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
class={[
|
||||
'px-3 py-2 text-ink select-none',
|
||||
i === picker.activeIndex ? 'bg-muted' : '',
|
||||
disabled
|
||||
? 'cursor-default opacity-50'
|
||||
: 'cursor-pointer hover:bg-muted focus:bg-muted focus:outline-none'
|
||||
|
||||
Reference in New Issue
Block a user