diff --git a/src/interface/components/Select.svelte b/src/interface/components/Select.svelte index 007e16c3..c0d6d3f8 100644 --- a/src/interface/components/Select.svelte +++ b/src/interface/components/Select.svelte @@ -5,29 +5,149 @@ options: Array<{ value: string, label: string }> }>(); + const listboxId = `select-listbox-${Math.random().toString(36).slice(2, 9)}`; + let isOpen = $state(false); + let activeIndex = $state(0); let root: HTMLDivElement | undefined = $state(); + let trigger: HTMLButtonElement | undefined = $state(); + let listbox: HTMLUListElement | undefined = $state(); const selectedLabel = $derived( options.find((option) => option.value === value)?.label ?? value, ); + const selectedIndex = $derived( + options.findIndex((option) => option.value === value), + ); + + const activeDescendantId = $derived( + isOpen && options[activeIndex] + ? optionId(options[activeIndex].value) + : undefined, + ); + + function optionId(optionValue: string): string { + return `${listboxId}-option-${optionValue}`; + } + + function openMenu(preferredIndex?: number) { + isOpen = true; + activeIndex = + preferredIndex ?? + (selectedIndex >= 0 ? selectedIndex : 0); + } + + function closeMenu(returnFocus = true) { + isOpen = false; + if (returnFocus) { + trigger?.focus(); + } + } + function toggleOpen() { - isOpen = !isOpen; + if (isOpen) { + closeMenu(); + } else { + openMenu(); + } } function selectValue(nextValue: string) { onChange(nextValue); - isOpen = false; + closeMenu(); + } + + function selectActive() { + const option = options[activeIndex]; + if (option) { + selectValue(option.value); + } + } + + function moveActive(delta: number) { + if (!options.length) return; + activeIndex = (activeIndex + delta + options.length) % options.length; + } + + function onTriggerKeydown(event: KeyboardEvent) { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + if (isOpen) { + moveActive(1); + } else { + openMenu(); + } + break; + case "ArrowUp": + event.preventDefault(); + if (isOpen) { + moveActive(-1); + } else { + openMenu(); + } + break; + case "Enter": + case " ": + event.preventDefault(); + if (isOpen) { + selectActive(); + } else { + openMenu(); + } + break; + case "Escape": + if (isOpen) { + event.preventDefault(); + closeMenu(); + } + break; + } + } + + function onListboxKeydown(event: KeyboardEvent) { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + moveActive(1); + break; + case "ArrowUp": + event.preventDefault(); + moveActive(-1); + break; + case "Home": + event.preventDefault(); + activeIndex = 0; + break; + case "End": + event.preventDefault(); + activeIndex = Math.max(0, options.length - 1); + break; + case "Enter": + case " ": + event.preventDefault(); + selectActive(); + break; + case "Escape": + event.preventDefault(); + closeMenu(); + break; + case "Tab": + closeMenu(false); + break; + } } $effect(() => { if (!isOpen) return; + queueMicrotask(() => listbox?.focus()); + const onPointerDown = (event: PointerEvent) => { const path = event.composedPath(); if (root && path.includes(root)) return; - isOpen = false; + closeMenu(false); }; document.addEventListener("pointerdown", onPointerDown, true); @@ -37,11 +157,14 @@
{#if isOpen} -