brackt/app/routes/onboarding.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

111 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { redirect, Form } from "react-router";
import { auth } from "~/lib/auth.server";
import { findUserById, findUserByUsername, updateUser, USERNAME_RE } from "~/models/user";
import type { Route } from "./+types/onboarding";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
export function meta(): Route.MetaDescriptors {
return [{ title: "Choose a Username - Brackt" }];
}
export function suggestUsername(displayName: string | null): string {
if (!displayName) return "";
const cleaned = displayName
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "")
.slice(0, 20);
return cleaned.length >= 3 ? cleaned : "";
}
export function safeRedirectTo(value: string | null): string {
if (!value) return "/";
return value.startsWith("/") && !value.startsWith("//") ? value : "/";
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return redirect("/login");
const user = await findUserById(session.user.id);
if (!user) return redirect("/login");
if (user.username) return redirect("/");
const redirectTo = safeRedirectTo(new URL(request.url).searchParams.get("redirectTo"));
return { suggestion: suggestUsername(user.displayName), redirectTo };
}
export async function action({ request }: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return redirect("/login");
const formData = await request.formData();
const username = (formData.get("username") as string | null)?.trim() ?? "";
const redirectTo = safeRedirectTo(formData.get("redirectTo") as string | null);
if (!USERNAME_RE.test(username)) {
return {
error: "Username must be 330 characters and contain only letters, numbers, underscores, or hyphens.",
redirectTo,
};
}
const existing = await findUserByUsername(username);
if (existing && existing.id !== session.user.id) {
return { error: "That username is already taken.", redirectTo };
}
try {
await updateUser(session.user.id, { username });
} catch {
return { error: "That username is already taken.", redirectTo };
}
return redirect(redirectTo);
}
export default function OnboardingPage({ loaderData, actionData }: Route.ComponentProps) {
const redirectTo = actionData && "redirectTo" in actionData ? actionData.redirectTo : loaderData.redirectTo;
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl">Choose a username</CardTitle>
<CardDescription>
Pick a username that will be shown to other players. You can change it later in Settings.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="redirectTo" value={redirectTo} />
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
defaultValue={loaderData.suggestion}
placeholder="e.g. fantasy_king"
minLength={3}
maxLength={30}
autoFocus
autoComplete="off"
aria-describedby={actionData && "error" in actionData ? "onboarding-error" : "username-hint"}
/>
<p id="username-hint" className="text-xs text-muted-foreground">
330 characters. Letters, numbers, underscores, and hyphens only.
</p>
</div>
{actionData && "error" in actionData && (
<p id="onboarding-error" role="alert" className="text-sm text-destructive">{actionData.error}</p>
)}
<Button type="submit" className="w-full">
Continue
</Button>
</Form>
</CardContent>
</Card>
</div>
);
}