From 331d6bb910e74d79cd4be5cd2f72f6b7a5cde92c Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Mon, 11 May 2026 04:19:39 +0000 Subject: [PATCH] fix(canvas/a11y): add keyboard navigation + focus rings to ThemeToggle radiogroup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WCAG 2.1.1: Arrow keys (Left/Right/Up/Down) now move focus between theme options and update the selection. Home/End jump to first/last. Previously the radiogroup had no keyboard support — only mouse clicks worked. WCAG 2.4.7: All three theme icon buttons now have focus-visible:ring-2 focus-visible:ring-accent rings so keyboard-only users can see which option has focus. 8 new tests in ThemeToggle.test.tsx cover all keyboard paths. Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/ThemeToggle.tsx | 38 ++++++++- .../components/__tests__/ThemeToggle.test.tsx | 85 ++++++++++++++++++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/canvas/src/components/ThemeToggle.tsx b/canvas/src/components/ThemeToggle.tsx index c99519b8..322ff3df 100644 --- a/canvas/src/components/ThemeToggle.tsx +++ b/canvas/src/components/ThemeToggle.tsx @@ -1,6 +1,7 @@ "use client"; import { useTheme, type ThemePreference } from "@/lib/theme-provider"; +import { useCallback } from "react"; const OPTIONS: { value: ThemePreference; label: string; icon: string }[] = [ // Sun: explicit light @@ -33,17 +34,47 @@ const OPTIONS: { value: ThemePreference; label: string; icon: string }[] = [ * * Aligned with molecule-app/components/theme-toggle.tsx so the picker * behaves identically across surfaces. + * + * WCAG 2.4.7: focus-visible rings on all three icon buttons. + * ARIA radiogroup pattern (2.1.1): Left/Right arrow keys move focus + * between options and update selection; Home/End jump to first/last. */ export function ThemeToggle({ className = "" }: { className?: string }) { const { theme, setTheme } = useTheme(); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent, index: number) => { + let next = index; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + next = (index + 1) % OPTIONS.length; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + next = (index - 1 + OPTIONS.length) % OPTIONS.length; + } else if (e.key === "Home") { + e.preventDefault(); + next = 0; + } else if (e.key === "End") { + e.preventDefault(); + next = OPTIONS.length - 1; + } else { + return; + } + setTheme(OPTIONS[next].value); + // Move focus to the new button so arrow-key navigation is continuous + const btns = (e.currentTarget.closest("[role=radiogroup]") as HTMLElement)?.querySelectorAll("[role=radio]"); + btns?.[next]?.focus(); + }, + [] + ); + return (
- {OPTIONS.map((opt) => { + {OPTIONS.map((opt, index) => { const active = theme === opt.value; return (