From c9977a20e9d8f027df0da6e302b16a308cc47415 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:42:53 +0200 Subject: [PATCH] chore(orbit): keyboard navigation across interactive modals Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/OrbitAccountPicker.tsx | 46 +++++++++++++++++++++++---- src/components/OrbitExitModal.tsx | 36 ++++++++++++++------- src/components/OrbitHelpModal.tsx | 30 +++++++++++++++-- src/components/OrbitStartModal.tsx | 6 ++++ src/styles/components.css | 3 +- 5 files changed, 99 insertions(+), 22 deletions(-) diff --git a/src/components/OrbitAccountPicker.tsx b/src/components/OrbitAccountPicker.tsx index f467e547..6358ec2b 100644 --- a/src/components/OrbitAccountPicker.tsx +++ b/src/components/OrbitAccountPicker.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { X, User } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -13,13 +13,43 @@ import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; export default function OrbitAccountPicker() { const { t } = useTranslation(); const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore(); + const [selected, setSelected] = useState(0); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + + // Reset + focus first item each time the picker re-opens. + useEffect(() => { + if (!isOpen) return; + setSelected(0); + // Defer focus to the next tick so the DOM has actually mounted. + queueMicrotask(() => itemRefs.current[0]?.focus()); + }, [isOpen]); + + // Move DOM focus with the arrow-key selection so the browser's focus + // ring follows, and the currently active button is readable to AT. + useEffect(() => { + if (!isOpen) return; + itemRefs.current[selected]?.focus(); + }, [selected, isOpen]); useEffect(() => { if (!isOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancel(); }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { cancel(); return; } + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelected(s => (s + 1) % Math.max(1, accounts.length)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelected(s => (s - 1 + accounts.length) % Math.max(1, accounts.length)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const target = accounts[selected]; + if (target) pick(target); + } + }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [isOpen, cancel]); + }, [isOpen, accounts, selected, pick, cancel]); if (!isOpen) return null; @@ -42,13 +72,15 @@ export default function OrbitAccountPicker() {

{t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}

-