From 4bbcac194950de9909f7d45e01a4019a630a2612 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 17 May 2026 20:11:38 -0700 Subject: [PATCH] fix: resolve all 48 WCAG 2.2 AA accessibility issues (#439) * fix: resolve all 48 WCAG 2.2 AA accessibility issues Critical fixes: - Add aria-label to all unlabeled inputs/selects in draft dialogs (ParticipantSelectionDialog, TimeBankAdjustmentDialog, AvailableParticipantsSection) - Add role="dialog" + aria-modal + focus trap to ConnectionOverlay and AuthRecoveryOverlay - Add aria-live region and connection status announcement to ConnectionOverlay Serious fixes: - Add skip-to-content link in root.tsx with id="main-content" on
- Add aria-label to UserMenu trigger button - Add aria-describedby + role="alert" to all auth form error messages (login, register, onboarding, forgot-password, reset-password) - Replace emoji column headers in StandingsTable with aria-label + aria-hidden spans - Add aria-live="assertive" to "It's your turn" desktop and mobile on-clock indicators - Add aria-live="polite" to draft room countdown timer - Add pause button to SportTicker (WCAG 2.2.2); add aria-hidden to ticker content - Fix Footer text contrast (changed from 28% to text-muted-foreground) - Fix OvernightPauseSettings: add htmlFor/id pairs and role="radiogroup"+aria-checked to mode buttons - Fix DraftSetupSection: replace broken htmlFor with aria-label on date picker button - Add aria-label to PeopleSection owner and commissioner selects - Add labels to ScoringPresetPicker score inputs; add role="radiogroup"+aria-checked to preset buttons - Add role="radiogroup"+aria-checked to AutodraftSettings option buttons - Add accessible names, aria-current="step", and
    list semantics to WizardStepper Moderate fixes: - Add aria-controls to RecentPicksFeed toggle button; wrap picks list in aria-live region - Add role="tab"+aria-selected+aria-controls to mobile board sub-tabs + role="tabpanel" - Add role="radiogroup"+aria-checked to TimerModeSelector - Add aria-current="page" + aria-label to SettingsDesktopNav - Add aria-label="Admin navigation" to admin sidebar nav - Add scope="col" + to StandingsTable and ScoringTables - Add ARIA table roles (role="table/rowgroup/row/columnheader/rowheader/cell") to DraftSummaryView CSS grid Minor fixes: - Add aria-hidden="true" to decorative trend icons in StandingsTable - Add aria-hidden="true" to desktop column header labels row in AvailableParticipantsSection - Replace title with aria-label on all icon-only buttons (watchlist, queue) in AvailableParticipantsSection - Add aria-label to NotificationSettings switchOnly Switch - Add prefers-reduced-motion check to SlotMachineHeadline JS animation - Bump --muted-foreground from 55% to 62% opacity for improved contrast margin https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3 * 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
  1. 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 * Fix lint error and update tests for WCAG role changes - Replace el! non-null assertion with optional chaining in useFocusTrap - Update AutodraftSettings tests to query role="radio" instead of role="button" (buttons have an explicit radio role since the WCAG pass) - Update AvailableParticipantsSection watchlist tests to use getByRole/getAllByRole instead of getByTitle/getAllByTitle (watchlist buttons now use aria-label instead of title) https://claude.ai/code/session_01JXajpFxhqLf8aPCncP81k3 --------- Co-authored-by: Claude --- app/components/AutodraftSettings.tsx | 47 +++++++++++-- app/components/NotificationSettings.tsx | 1 + app/components/StandingsTable.tsx | 34 +++++----- app/components/UserMenu.tsx | 2 +- .../__tests__/AutodraftSettings.test.tsx | 64 ++++++++--------- .../AvailableParticipantsSection.test.tsx | 10 +-- app/components/draft/AuthRecoveryOverlay.tsx | 34 ++++++---- .../draft/AvailableParticipantsSection.tsx | 52 +++++++------- app/components/draft/ConnectionOverlay.tsx | 68 ++++++++++--------- app/components/draft/DraftSummaryView.tsx | 20 ++++-- .../draft/ParticipantSelectionDialog.tsx | 4 ++ app/components/draft/RecentPicksFeed.tsx | 3 +- .../draft/TimeBankAdjustmentDialog.tsx | 9 ++- .../league/OvernightPauseSettings.tsx | 32 +++++++-- app/components/league/ScoringPresetPicker.tsx | 24 ++++++- app/components/league/TimerModeSelector.tsx | 20 +++++- app/components/league/WizardStepper.tsx | 42 ++++++------ .../league/settings/DraftSetupSection.tsx | 5 +- .../league/settings/PeopleSection.tsx | 4 +- .../league/settings/SettingsNavigation.tsx | 5 +- app/components/marketing/Footer.tsx | 3 +- app/components/marketing/LandingPage.tsx | 23 ++++++- app/components/marketing/ScoringTables.tsx | 10 +-- app/hooks/useFocusTrap.ts | 31 +++++++++ app/root.tsx | 8 ++- app/routes/admin.tsx | 2 +- app/routes/forgot-password.tsx | 4 +- .../leagues/$leagueId.draft.$seasonId.tsx | 16 +++-- app/routes/login.tsx | 6 +- app/routes/onboarding.tsx | 5 +- app/routes/register.tsx | 8 +-- app/routes/reset-password.tsx | 4 +- 32 files changed, 404 insertions(+), 196 deletions(-) create mode 100644 app/hooks/useFocusTrap.ts diff --git a/app/components/AutodraftSettings.tsx b/app/components/AutodraftSettings.tsx index d033216..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; @@ -209,6 +227,10 @@ function AutodraftOptions({ ); @@ -385,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 */} @@ -417,7 +452,7 @@ export function AutodraftSettings({
    -
    +
    {OPTION_ORDER.map((state) => { const { label } = OPTIONS[state]; const isActive = localState === state; @@ -425,6 +460,10 @@ export function AutodraftSettings({ ); diff --git a/app/components/NotificationSettings.tsx b/app/components/NotificationSettings.tsx index 51d9225..3bf1d8f 100644 --- a/app/components/NotificationSettings.tsx +++ b/app/components/NotificationSettings.tsx @@ -28,6 +28,7 @@ export function NotificationSettings({ return ( <> 0) { return (
    - +
    ); } else if (change < 0) { return (
    - +
    ); } else { return (
    - +
    ); @@ -101,26 +101,26 @@ export function StandingsTable({ }; return ( - +
    - Rank - {showMovement && Change} - Team - Total Points + Rank + {showMovement && Change} + Team + Total Points {showPlacementBreakdown && ( <> - 🥇 - 🥈 - 🥉 - 4th - 5th - 6th - 7th - 8th + + + + 4th + 5th + 6th + 7th + 8th )} - Remaining + Remaining diff --git a/app/components/UserMenu.tsx b/app/components/UserMenu.tsx index 76763d6..7eed617 100644 --- a/app/components/UserMenu.tsx +++ b/app/components/UserMenu.tsx @@ -32,7 +32,7 @@ export function UserMenu({ return ( - diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index dbe8d78..f08618c 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -339,7 +339,9 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
    +
    -
    + + ) { + if (disabled) return; + if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(e.key)) return; + e.preventDefault(); + const idx = MODES.findIndex((m) => m.value === value); + const next = e.key === "ArrowDown" || e.key === "ArrowRight" + ? (idx + 1) % MODES.length + : (idx - 1 + MODES.length) % MODES.length; + const nextValue = MODES[next].value; + onChange(nextValue); + e.currentTarget.querySelector(`[data-radio-value="${nextValue}"]`)?.focus(); + } + return ( -
    +
    {MODES.map((mode) => { const selected = value === mode.value; return ( +
  2. + +
  3. ); })} -
    -
    + + -
    + ); } diff --git a/app/components/league/settings/DraftSetupSection.tsx b/app/components/league/settings/DraftSetupSection.tsx index d56577e..e34b16a 100644 --- a/app/components/league/settings/DraftSetupSection.tsx +++ b/app/components/league/settings/DraftSetupSection.tsx @@ -122,11 +122,12 @@ export function DraftSetupSection({
    - +
    ))} diff --git a/app/components/marketing/Footer.tsx b/app/components/marketing/Footer.tsx index a50f889..06f977e 100644 --- a/app/components/marketing/Footer.tsx +++ b/app/components/marketing/Footer.tsx @@ -11,8 +11,7 @@ const FOOTER_LINKS = [ export function Footer() { return (
    © 2026 Brackt. All rights reserved.
    + - - + + @@ -29,10 +30,11 @@ export function QualifyingPointsTable({ className }: TableProps) { return (
    Scoring points by finish position
    FinishPointsFinishPoints
    + - - + + diff --git a/app/hooks/useFocusTrap.ts b/app/hooks/useFocusTrap.ts new file mode 100644 index 0000000..c5d70ae --- /dev/null +++ b/app/hooks/useFocusTrap.ts @@ -0,0 +1,31 @@ +import { useEffect, type RefObject } from "react"; + +const FOCUSABLE_SELECTOR = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function useFocusTrap(ref: RefObject, active: boolean) { + useEffect(() => { + if (!active) return; + const el = ref.current; + if (!el) return; + + const first = el.querySelector(FOCUSABLE_SELECTOR) ?? el; + first.focus(); + + function onKeyDown(e: KeyboardEvent) { + if (e.key !== "Tab") return; + const focusables = Array.from(el?.querySelectorAll(FOCUSABLE_SELECTOR) ?? []); + if (focusables.length === 0) return; + const firstEl = focusables[0]; + const lastEl = focusables[focusables.length - 1]; + if (e.shiftKey) { + if (document.activeElement === firstEl) { e.preventDefault(); lastEl.focus(); } + } else { + if (document.activeElement === lastEl) { e.preventDefault(); firstEl.focus(); } + } + } + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [ref, active]); +} diff --git a/app/root.tsx b/app/root.tsx index d9dc04d..4ddd0d4 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -77,6 +77,12 @@ export function Layout({ children }: { children: React.ReactNode }) { + + Skip to main content + {children} @@ -103,7 +109,7 @@ export default function App({ loaderData }: Route.ComponentProps) { ) : ( <> -
    +
    diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index 1fb88f5..a7338f8 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -43,7 +43,7 @@ export default function AdminLayout() {

    Admin Panel

    -
    Qualifying points by major finish position
    Major FinishQualifying PointsMajor FinishQualifying Points