diff --git a/apps/sisyphus/src/renderer/src/components/language-picker.tsx b/apps/sisyphus/src/renderer/src/components/language-picker.tsx index bcfc4e9..a9953e8 100644 --- a/apps/sisyphus/src/renderer/src/components/language-picker.tsx +++ b/apps/sisyphus/src/renderer/src/components/language-picker.tsx @@ -29,7 +29,17 @@ export function LanguagePicker({ label={label} value={selectedLabel} readOnly + role="button" onClick={() => setOpen((current) => !current)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") { + event.preventDefault(); + setOpen(true); + } else if (event.key === "Escape" && open) { + event.preventDefault(); + setOpen(false); + } + }} aria-haspopup="dialog" aria-expanded={open} aria-busy={loading} diff --git a/apps/sisyphus/src/renderer/src/components/ui/responsive-selector.tsx b/apps/sisyphus/src/renderer/src/components/ui/responsive-selector.tsx index 12df9e6..f01d3e5 100644 --- a/apps/sisyphus/src/renderer/src/components/ui/responsive-selector.tsx +++ b/apps/sisyphus/src/renderer/src/components/ui/responsive-selector.tsx @@ -1,7 +1,7 @@ "use client"; import type { Dispatch, ReactNode, SetStateAction } from "react"; -import { useSyncExternalStore } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { Drawer } from "vaul"; import { AnimatePresence, motion } from "motion/react"; @@ -16,6 +16,10 @@ const subscribeToCompactViewport = (callback: () => void) => { const getCompactViewport = () => window.matchMedia("(max-width: 767px)").matches; +const focusableItems = (content: HTMLElement) => Array.from(content.querySelectorAll( + 'button:not(:disabled), a[href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]', +)).filter((item) => item.getClientRects().length > 0); + type SelectorApi = { open: boolean; setOpen: Dispatch>; @@ -34,6 +38,25 @@ export function ResponsiveSelector({ label, direction, trigger, children, classN const compact = useSyncExternalStore(subscribeToCompactViewport, getCompactViewport, () => false); const api = { open, setOpen }; + useEffect(() => { + // The mobile drawer manages its own focus. Desktop content lives in a portal, + // so ordinary Tab order would otherwise skip it for the underlying form. + if (!open || compact) return; + const content = contentRef.current; + if (!content) return; + const previousFocus = document.activeElement; + const frame = requestAnimationFrame(() => { + (focusableItems(content)[0] ?? content).focus({ preventScroll: true }); + }); + return () => { + cancelAnimationFrame(frame); + if (previousFocus instanceof HTMLElement && previousFocus.isConnected + && (content.contains(document.activeElement) || document.activeElement === document.body)) { + previousFocus.focus({ preventScroll: true }); + } + }; + }, [open, compact, contentRef]); + return (
{trigger(api)} @@ -56,7 +79,30 @@ export function ResponsiveSelector({ label, direction, trigger, children, classN ref={contentRef} key="responsive-selector" role="dialog" + tabIndex={-1} aria-label={label} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } else if (event.key === "Tab") { + const content = event.currentTarget; + const items = focusableItems(content); + const first = items[0]; + const last = items.at(-1); + if (!first) { + event.preventDefault(); + content.focus(); + } else if (event.shiftKey && (document.activeElement === first || document.activeElement === content)) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }} style={floatingStyle} initial={{ opacity: 0, y: placement === "top" ? 10 : -10, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }}