* 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 <main> - 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 <ol> 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" + <caption> 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 <th>) - Remove redundant aria-label from ParticipantSelectionDialog sport select (htmlFor label is sufficient) - Change WizardStepper connector <li> 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 <noreply@anthropic.com>
198 lines
7 KiB
TypeScript
198 lines
7 KiB
TypeScript
import { useState, useEffect, useMemo } from "react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "~/components/ui/dialog";
|
|
|
|
interface ParticipantSelectionDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
title: string;
|
|
description: string;
|
|
participants: Array<{
|
|
id: string;
|
|
name: string;
|
|
sport: { id: string; name: string };
|
|
}>;
|
|
draftedParticipantIds: Set<string>;
|
|
/** Pass the old participant's ID to keep them in the list (replace-pick dialog) */
|
|
allowParticipantId?: string;
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, DraftIneligibilityReason>;
|
|
} | null;
|
|
/** Marks a row with "Current" badge and "Keep" button label */
|
|
currentParticipantId?: string;
|
|
uniqueSports: string[];
|
|
onSelect: (participantId: string) => void;
|
|
}
|
|
|
|
export function ParticipantSelectionDialog({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
description,
|
|
participants,
|
|
draftedParticipantIds,
|
|
allowParticipantId,
|
|
eligibility,
|
|
currentParticipantId,
|
|
uniqueSports,
|
|
onSelect,
|
|
}: ParticipantSelectionDialogProps) {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [sportFilter, setSportFilter] = useState("all");
|
|
|
|
// Reset filters each time the dialog opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
setSearchQuery("");
|
|
setSportFilter("all");
|
|
}
|
|
}, [open]);
|
|
|
|
const filtered = useMemo(
|
|
() =>
|
|
participants.filter((p) => {
|
|
if (p.id !== allowParticipantId && draftedParticipantIds.has(p.id))
|
|
return false;
|
|
if (
|
|
searchQuery &&
|
|
!p.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
return false;
|
|
if (sportFilter !== "all" && p.sport.name !== sportFilter) return false;
|
|
return true;
|
|
}),
|
|
[participants, draftedParticipantIds, allowParticipantId, searchQuery, sportFilter]
|
|
);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0">
|
|
<DialogHeader className="p-6 pb-4">
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="px-6 pb-4">
|
|
<div className="flex gap-2 mb-4">
|
|
<label htmlFor="participant-dialog-search" className="sr-only">Search participants</label>
|
|
<input
|
|
id="participant-dialog-search"
|
|
type="text"
|
|
placeholder="Search participants..."
|
|
className="flex-1 px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
<label htmlFor="participant-dialog-sport" className="sr-only">Filter by sport</label>
|
|
<select
|
|
id="participant-dialog-sport"
|
|
className="px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
|
|
value={sportFilter}
|
|
onChange={(e) => setSportFilter(e.target.value)}
|
|
>
|
|
<option value="all">All Sports</option>
|
|
{uniqueSports.map((sport) => (
|
|
<option key={sport} value={sport}>
|
|
{sport}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="max-h-96 overflow-y-auto border rounded-lg">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted sticky top-0">
|
|
<tr>
|
|
<th className="text-left p-3 font-semibold">Participant</th>
|
|
<th className="text-left p-3 font-semibold">Sport</th>
|
|
<th className="text-right p-3 font-semibold">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map((participant) => {
|
|
const isCurrentPick =
|
|
participant.id === currentParticipantId;
|
|
const isEligible = eligibility
|
|
? eligibility.eligibleSportIds.has(participant.sport.id)
|
|
: true;
|
|
const ineligibleReason =
|
|
eligibility?.ineligibleReasons[participant.sport.id] ?? null;
|
|
|
|
return (
|
|
<tr
|
|
key={participant.id}
|
|
className={`border-t ${
|
|
isEligible
|
|
? "hover:bg-muted/50"
|
|
: "bg-destructive/10 opacity-60"
|
|
}`}
|
|
title={!isEligible ? ineligibleReason?.message : undefined}
|
|
>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}
|
|
>
|
|
{participant.name}
|
|
</span>
|
|
{isCurrentPick && (
|
|
<Badge variant="outline" className="text-xs">
|
|
Current
|
|
</Badge>
|
|
)}
|
|
{!isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason?.message}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{!isEligible && ineligibleReason && (
|
|
<p className="mt-1 text-xs text-destructive/90">
|
|
{ineligibleReason.message}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="p-3 text-muted-foreground">
|
|
{participant.sport.name}
|
|
</td>
|
|
<td className="p-3 text-right">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => onSelect(participant.id)}
|
|
disabled={!isEligible}
|
|
title={!isEligible ? ineligibleReason?.message : undefined}
|
|
>
|
|
{isCurrentPick ? "Keep" : "Draft"}
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="px-6 pb-6">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|