From e25cba09aca43f21bd5e65139363d53b16ead97c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 17 May 2026 23:46:57 +0000 Subject: [PATCH] Fix code review findings from WCAG compliance pass - Add Arrow key navigation + roving tabindex to all role=radiogroup components (AutodraftSettings x2, TimerModeSelector, OvernightPauseSettings, ScoringPresetPicker) per ARIA radio pattern - Extract shared focus-trap logic into useFocusTrap hook; update ConnectionOverlay and AuthRecoveryOverlay to use it - Add tabIndex={-1} to ConnectionOverlay Card so focus can land in spinner-only state (no interactive children) - Replace aria-live on loading dots container with sr-only span so status changes are announced by text content, not aria-label - Remove contradictory aria-hidden+role=columnheader from AvailableParticipantsSection visual-only header row - Remove invalid scope="col" from div[role=columnheader] in DraftSummaryView (scope is only valid on ) - Remove redundant aria-label from ParticipantSelectionDialog sport select (htmlFor label is sufficient) - Change WizardStepper connector
  • to role=presentation - Revert muted-foreground from 62% to 55% (original already passes contrast; footer was fixed separately via text-muted-foreground) https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3 --- app/app.css | 2 +- app/components/AutodraftSettings.tsx | 39 ++++++++++- app/components/draft/AuthRecoveryOverlay.tsx | 48 +++----------- .../draft/AvailableParticipantsSection.tsx | 8 +-- app/components/draft/ConnectionOverlay.tsx | 64 +++++-------------- app/components/draft/DraftSummaryView.tsx | 5 +- .../draft/ParticipantSelectionDialog.tsx | 1 - .../league/OvernightPauseSettings.tsx | 20 +++++- app/components/league/ScoringPresetPicker.tsx | 20 +++++- app/components/league/TimerModeSelector.tsx | 18 +++++- app/components/league/WizardStepper.tsx | 2 +- app/hooks/useFocusTrap.ts | 31 +++++++++ 12 files changed, 155 insertions(+), 103 deletions(-) create mode 100644 app/hooks/useFocusTrap.ts diff --git a/app/app.css b/app/app.css index 9bc1f29..531d075 100644 --- a/app/app.css +++ b/app/app.css @@ -69,7 +69,7 @@ html { --secondary: #1c1f26; --secondary-foreground: #ffffff; --muted: #1c1f26; - --muted-foreground: rgb(255 255 255 / 62%); + --muted-foreground: rgb(255 255 255 / 55%); --accent: #2ce1c1; --accent-foreground: #14171e; --destructive: oklch(0.65 0.22 25); diff --git a/app/components/AutodraftSettings.tsx b/app/components/AutodraftSettings.tsx index 1587573..dbc64c4 100644 --- a/app/components/AutodraftSettings.tsx +++ b/app/components/AutodraftSettings.tsx @@ -200,8 +200,26 @@ function AutodraftOptions({ const isDisabled = isMyTurn; + function handleGroupKeyDown(e: React.KeyboardEvent) { + if (isDisabled) return; + if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(e.key)) return; + e.preventDefault(); + const idx = OPTION_ORDER.indexOf(localState); + const next = e.key === "ArrowDown" || e.key === "ArrowRight" + ? (idx + 1) % OPTION_ORDER.length + : (idx - 1 + OPTION_ORDER.length) % OPTION_ORDER.length; + const nextState = OPTION_ORDER[next]; + handleStateChange(nextState); + e.currentTarget.querySelector(`[data-radio-value="${nextState}"]`)?.focus(); + } + return ( -
    +
    {OPTION_ORDER.map((state) => { const { label } = OPTIONS[state]; const isActive = localState === state; @@ -211,6 +229,8 @@ function AutodraftOptions({ type="button" role="radio" aria-checked={isActive} + tabIndex={isActive ? 0 : -1} + data-radio-value={state} disabled={isDisabled} onClick={() => handleStateChange(state)} className={`w-full py-2.5 px-3 text-left transition-colors border-b last:border-b-0 flex items-center justify-between gap-2 ${getButtonClassName(state, isActive)} ${ @@ -387,6 +407,19 @@ export function AutodraftSettings({ const isDisabled = isMyTurn; + function handleGroupKeyDown(e: React.KeyboardEvent) { + if (isDisabled) return; + if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(e.key)) return; + e.preventDefault(); + const idx = OPTION_ORDER.indexOf(localState); + const next = e.key === "ArrowDown" || e.key === "ArrowRight" + ? (idx + 1) % OPTION_ORDER.length + : (idx - 1 + OPTION_ORDER.length) % OPTION_ORDER.length; + const nextState = OPTION_ORDER[next]; + handleStateChange(nextState); + e.currentTarget.querySelector(`[data-radio-value="${nextState}"]`)?.focus(); + } + return (
    {/* Header with info icon inline */} @@ -419,7 +452,7 @@ export function AutodraftSettings({
    -
    +
    {OPTION_ORDER.map((state) => { const { label } = OPTIONS[state]; const isActive = localState === state; @@ -429,6 +462,8 @@ export function AutodraftSettings({ type="button" role="radio" aria-checked={isActive} + tabIndex={isActive ? 0 : -1} + data-radio-value={state} disabled={isDisabled} onClick={() => handleStateChange(state)} className={`w-full py-2.5 px-3 text-left transition-colors border-b last:border-b-0 flex items-center justify-between gap-2 ${getButtonClassName(state, isActive)} ${ diff --git a/app/components/draft/AuthRecoveryOverlay.tsx b/app/components/draft/AuthRecoveryOverlay.tsx index 900939b..58b0a24 100644 --- a/app/components/draft/AuthRecoveryOverlay.tsx +++ b/app/components/draft/AuthRecoveryOverlay.tsx @@ -1,46 +1,18 @@ -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import { Button } from "~/components/ui/button"; import { Card } from "~/components/ui/card"; +import { useFocusTrap } from "~/hooks/useFocusTrap"; interface AuthRecoveryOverlayProps { isAuthDegraded: boolean; } -export function AuthRecoveryOverlay({ - isAuthDegraded, -}: AuthRecoveryOverlayProps) { +export function AuthRecoveryOverlay({ isAuthDegraded }: AuthRecoveryOverlayProps) { const dialogRef = useRef(null); - useEffect(() => { - if (!isAuthDegraded) return; - const el = dialogRef.current; - if (!el) return; - const focusable = el.querySelector( - 'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])' - ); - focusable?.focus(); + useFocusTrap(dialogRef, isAuthDegraded); - function trapFocus(e: KeyboardEvent) { - if (e.key !== "Tab") return; - const focusables = el?.querySelectorAll( - 'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])' - ); - if (!focusables || focusables.length === 0) return; - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - if (e.shiftKey) { - if (document.activeElement === first) { e.preventDefault(); last.focus(); } - } else { - if (document.activeElement === last) { e.preventDefault(); first.focus(); } - } - } - document.addEventListener("keydown", trapFocus); - return () => document.removeEventListener("keydown", trapFocus); - }, [isAuthDegraded]); - - if (!isAuthDegraded) { - return null; - } + if (!isAuthDegraded) return null; return (

    Session Expired

    - Your session has expired. Reload the page to reconnect to the - draft. + Your session has expired. Reload the page to reconnect to the draft.

    -
    diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index 0c15a03..f08618c 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -474,10 +474,10 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
    -