brackt/app/components/draft/DraftSummaryView.tsx
Claude b47b3d2eb5
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
2026-05-17 16:11:44 +00:00

174 lines
6.6 KiB
TypeScript

import { memo, useMemo, Fragment } from "react";
import { TeamAvatar } from "~/components/TeamAvatar";
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
import type { RawFlagConfig } from "~/lib/flag-types";
interface DraftSummaryViewProps {
draftSlots: Array<{
id: string;
team: {
id: string;
name: string;
logoUrl?: string | null;
flagConfig?: RawFlagConfig | null;
avatarType?: string | null;
};
}>;
ownerMap?: Record<string, string>;
picks: DraftPick[];
sports: Array<{ id: string; name: string }>;
totalRounds: number;
}
export const DraftSummaryView = memo(function DraftSummaryView({
draftSlots,
ownerMap = {},
picks,
sports,
totalRounds,
}: DraftSummaryViewProps) {
const picksByTeamAndSport = useMemo(
() => groupPicksByTeamAndSport(picks),
[picks]
);
const sportStats = useMemo(() => {
const map = new Map<string, { total: number; teams: number }>();
sports.forEach((sport) => map.set(sport.id, { total: 0, teams: 0 }));
picksByTeamAndSport.forEach((teamMap) => {
teamMap.forEach((teamSportPicks, sportId) => {
const stat = map.get(sportId);
if (stat && teamSportPicks.length > 0) {
stat.total += teamSportPicks.length;
stat.teams++;
}
});
});
return map;
}, [picksByTeamAndSport, sports]);
const flexPicksAvailable = Math.max(0, totalRounds - sports.length);
const flexPicksUsedByTeam = useMemo(() => {
const map = new Map<string, number>();
picksByTeamAndSport.forEach((teamMap, teamId) => {
let used = 0;
teamMap.forEach((teamSportPicks) => {
if (teamSportPicks.length > 1) used += teamSportPicks.length - 1;
});
map.set(teamId, used);
});
return map;
}, [picksByTeamAndSport]);
if (sports.length === 0) {
return (
<div className="flex items-center justify-center h-full p-8 text-muted-foreground">
<p>No sports have been configured for this season.</p>
</div>
);
}
const gridCols = `140px 96px repeat(${draftSlots.length}, minmax(80px, 1fr))`;
return (
<div className="w-full h-full overflow-auto">
<div role="table" aria-label="Draft summary by sport and team" className="grid w-full" style={{ gridTemplateColumns: gridCols }}>
{/* Header row */}
<div role="rowgroup">
<div role="row" className="contents">
<div role="columnheader" scope="col" className="sticky top-0 left-0 z-30 bg-muted border-r border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Sport
</div>
<div role="columnheader" scope="col" className="sticky top-0 z-20 bg-muted border-r border-b border-border px-2 py-2 text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Total
</div>
{draftSlots.map((slot, index) => {
const ownerName = ownerMap[slot.team.id];
const isLast = index === draftSlots.length - 1;
const used = flexPicksUsedByTeam.get(slot.team.id) ?? 0;
const flexesExhausted = flexPicksAvailable > 0 && used >= flexPicksAvailable;
return (
<div
role="columnheader"
scope="col"
key={slot.id}
className={`sticky top-0 z-20 bg-muted/40 border-b border-border px-1 py-2 min-w-0 ${!isLast ? "border-r" : ""}`}
>
<div className="flex flex-col items-center gap-1">
<TeamAvatar
teamId={slot.team.id}
teamName={ownerName || slot.team.name}
logoUrl={slot.team.logoUrl}
flagConfig={slot.team.flagConfig}
avatarType={slot.team.avatarType}
size="md"
/>
<div className="text-xs font-semibold text-center truncate w-full">
{ownerName || slot.team.name}
</div>
{flexPicksAvailable > 0 && (
<span className={`text-xs ${flexesExhausted ? "text-destructive font-medium" : "text-muted-foreground"}`}>
{used}/{flexPicksAvailable} flex picks
</span>
)}
</div>
</div>
);
})}
</div>{/* end header row */}
</div>{/* end rowgroup */}
{/* Data rows */}
<div role="rowgroup">
{sports.map((sport, sportIndex) => {
const isLastRow = sportIndex === sports.length - 1;
const { total: sportTotal, teams: sportTeamCount } =
sportStats.get(sport.id) ?? { total: 0, teams: 0 };
return (
<Fragment key={sport.id}>
<div role="row" className="contents">
<div role="rowheader" className={`sticky left-0 z-10 bg-muted border-r border-border px-3 py-2.5 font-medium text-sm whitespace-nowrap ${!isLastRow ? "border-b" : ""}`}>
{sport.name}
</div>
<div role="cell" className={`border-r border-border px-2 py-2.5 text-center bg-muted/20 ${!isLastRow ? "border-b" : ""}`}>
{sportTotal > 0 ? (
<>
<div className="text-sm font-bold tabular-nums">{sportTotal}</div>
<div className="text-xs text-muted-foreground">{sportTeamCount}/{draftSlots.length} teams</div>
</>
) : (
<span className="text-muted-foreground/40 text-sm"></span>
)}
</div>
{draftSlots.map((slot, slotIndex) => {
const count = picksByTeamAndSport.get(slot.team.id)?.get(sport.id)?.length ?? 0;
const isLast = slotIndex === draftSlots.length - 1;
const isFlex = count >= 2;
return (
<div
role="cell"
key={`${slot.team.id}-${sport.id}`}
className={`border-border px-3 py-2.5 text-center ${isFlex ? "bg-amber-500/10" : "bg-background"} ${!isLastRow ? "border-b" : ""} ${!isLast ? "border-r" : ""}`}
>
{count > 0 ? (
<span className={`text-sm font-bold tabular-nums ${isFlex ? "text-amber-600 dark:text-amber-400" : ""}`}>
{count}
</span>
) : (
<span className="text-muted-foreground/30 text-sm"></span>
)}
</div>
);
})}
</div>{/* end row */}
</Fragment>
);
})}
</div>{/* end rowgroup */}
</div>
</div>
);
});