From abcbca37450618b4b82d64b796ecc128a8720b27 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 26 Jun 2026 20:02:29 +0930 Subject: [PATCH] fix: restore theme edit label and add Select keyboard a11y Restore the theme settings toggle copy to Edit/Done and add listbox keyboard navigation with aria-activedescendant for the custom Select. Co-authored-by: Cursor --- src/interface/components/Select.svelte | 161 +++++++++++++++++++++- src/interface/pages/settings/theme.svelte | 2 +- 2 files changed, 155 insertions(+), 8 deletions(-) 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} -
    - {#each options as option (option.value)} -
  • +
      + {#each options as option, index (option.value)} +
    • @@ -146,6 +284,14 @@ gap: 0.125rem; } + .select-menu:focus-visible { + outline: none; + box-shadow: + 0 10px 25px -5px rgb(0 0 0 / 0.25), + 0 8px 10px -6px rgb(0 0 0 / 0.2), + 0 0 0 1px color-mix(in srgb, var(--text-primary) 12%, transparent); + } + .select-option { display: block; width: 100%; @@ -163,7 +309,8 @@ } .select-option:hover, - .select-option:focus-visible { + .select-option:focus-visible, + .select-option.is-active { outline: none; background: var(--theme-secondary, #e5e7eb); } diff --git a/src/interface/pages/settings/theme.svelte b/src/interface/pages/settings/theme.svelte index 422afe69..fece578a 100644 --- a/src/interface/pages/settings/theme.svelte +++ b/src/interface/pages/settings/theme.svelte @@ -22,7 +22,7 @@