feat(a11y): make the app usable from a keyboard and a screen reader

eslint-plugin-jsx-a11y joins the lint step and found 19 violations on
arrival - 15 orphaned labels, a clickable div, a combobox missing its
required attributes, an autoFocus. All fixed, none suppressed, and the
rule set stays enforcing so the next one fails CI.

The two hand-rolled overlays were the worst of it. The notification bell
was a div with div-onClick rows: nothing focusable, nothing announced, no
way to close it from the keyboard. It is a real popover now - the
trigger reports its state and the unread count, the panel is a labelled
dialog, every row is a button with its actions beside it rather than
nested inside it, Escape closes and returns focus. The Challenge Friend
overlay had no role, no focus trap and no Escape, so Tab went straight
through it into the page behind; it is the UI kit's Dialog now, which
supplies all of that.

Every form control has a label, including the three change-password
fields that had only placeholders and a game-night join-password field
that had nothing at all. Every icon-only button says what it does,
including three versus buttons whose content collapsed to a bare spinner
while loading. The search combobox follows the ARIA 1.2 pattern, with
the role on the input; verified live that aria-expanded flips and
aria-controls resolves to the listbox.

State that changed silently is announced: link validation, both lobby
countdowns (assertively - a 3-2-1 that launches the game is not
something to miss), the game-night player list. The game header's
"Passed Movie B" read identically in both states and only the icon
differed, so a screen reader heard the wrong thing until B was reached.

A skip-to-content link on every page, and a global reduced-motion rule
that collapses every animation when the OS asks; the confetti overlay
does not render at all under it.

Addresses audit section 2.8.
This commit is contained in:
TehRiehlDeal committed 2026-09-11 18:45:06 -07:00
1 parent e67692006f
commit 889cdda83f
25 files changed
+2140 -109

No files matched your search

+58
View File
@@ -9,6 +9,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.1.0] - 2026-09-11
Accessibility (WP-16: audit section 2.8).
### Added
- `eslint-plugin-jsx-a11y` (recommended rules) in the lint step, so an orphaned
label, a clickable `<div>` or a missing ARIA attribute fails CI instead of
shipping. It found 19 violations on arrival; all are fixed, none suppressed.
- A "Skip to content" link on every page, visually hidden until focused, that
moves focus to the main region.
- Live regions where state used to change silently: link validation announces
"Validating", a rejection (as an alert) and "Connected"; the versus and
game-night lobby countdowns are assertive timers, so a screen-reader user is
no longer dropped into a running match with no warning; the game-night
player list announces players finishing or forfeiting.
- Tests for the notification popover's keyboard contract (9 tests).
### Changed
- **The notification bell is a real popover.** It was a `<div>` with
`<div onClick>` rows: nothing in it was focusable, nothing announced, no way
to close it from the keyboard. The trigger now reports its state and the
unread count in its name (`Notifications, 3 unread`), the panel is a labelled
dialog the trigger controls, every notification is a button, the per-row
controls have real names rather than tooltips, Escape closes and returns
focus to the trigger, and the unread dot has text behind it.
- **The Challenge Friend overlay is the UI kit's `Dialog`.** It was a fixed
`<div>` with no role, no focus trap and no Escape - Tab went straight through
it into the page behind. Base UI supplies the dialog role, `aria-modal`,
focus trapping, Escape and focus restoration.
- The search combobox follows the ARIA 1.2 pattern: `role="combobox"` with
`aria-expanded`/`aria-controls` on the input itself, rather than the 1.1
wrapper-`div`-with-`aria-owns` form that current screen readers announce
inconsistently. Verified live: the state flips and `aria-controls` resolves
to the listbox.
- Every form control has a label. Sign-in/sign-up, the three change-password
fields (which had only placeholders, and placeholders vanish once you type),
the admin challenge form, custom game, game night's room form (including a
join-password field that had nothing at all), the versus lobby form, the
friend search and the chat message box. Labels above groups of buttons
("Difficulty", "Time Limit", "Movie A") are group labels via
`role="group"`/`aria-labelledby` rather than a faked `htmlFor`.
- Every icon-only button has a name that says what it does: chat send, back,
pagination, display-name save/cancel, notification actions, and three versus
buttons whose content collapsed to a bare spinner while loading.
- The game header's "Passed Movie B" read the same text in both states - only
the icon and colour changed - so a screen reader heard the wrong thing until
B was reached. It now announces "Movie B reached" or "not yet reached", and
the online indicator on the friends list has text behind the green dot.
- `autoFocus` is gone from the friend search. It skipped screen-reader users
past the page heading; the field is one Tab away.
- **Reduced motion is respected globally.** A single stylesheet rule collapses
every animation and transition when the OS preference is set; the confetti
overlay does not render at all under it, since a still frame of confetti has
no meaning.
## [2.0.1] - 2026-09-11
Bundle and asset budget (WP-15: M26).
+6
View File
@@ -2,6 +2,7 @@ import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import jsxA11y from 'eslint-plugin-jsx-a11y'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
@@ -14,6 +15,11 @@ export default defineConfig([
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
// Accessibility at lint time. Before this, an orphaned <label>, an
// icon-only button with no name, or a clickable <div> with no role
// passed CI - which is how the whole login form, three password fields
// and a hand-rolled dialog ended up unreachable to a screen reader.
jsxA11y.flatConfigs.recommended,
],
languageOptions: {
// Matches the ES2022 target in tsconfig.app.json.
+1530 -2
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend-temp",
"private": true,
"version": "2.0.1",
"version": "2.1.0",
"type": "module",
"license": "MIT",
"author": "Kevin Riehl",
@@ -47,6 +47,7 @@
"@vitest/coverage-v8": "^5.0.0",
"commit-and-tag-version": "^12.5.0",
"eslint": "^9.39.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+11 -5
View File
@@ -13,7 +13,9 @@ export default function PlayersProgress() {
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold">Players</h3>
<div className="space-y-2">
{/* Polite, so a player finishing or forfeiting is announced without
interrupting what the user is doing. */}
<div className="space-y-2" aria-live="polite">
{activePlayers.map((player) => {
const chainLength = playerProgress.get(player.userId) ?? 0;
const isFinished = finishedPlayers.has(player.userId);
@@ -26,17 +28,21 @@ export default function PlayersProgress() {
>
<div className="flex items-center gap-2">
{isForfeited ? (
<Flag className="h-4 w-4 text-destructive" />
<Flag className="h-4 w-4 text-destructive" aria-hidden="true" />
) : isFinished ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" />
) : player.disconnected ? (
<WifiOff className="h-4 w-4 text-muted-foreground" />
<WifiOff className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
) : (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden="true" />
)}
<span className="text-sm font-medium">
{player.user.username}
</span>
{/* Disconnection is the one state with no text badge. */}
{player.disconnected && !isFinished && !isForfeited && (
<span className="sr-only">(disconnected)</span>
)}
</div>
<div className="flex items-center gap-2">
{isForfeited ? (
+21 -2
View File
@@ -28,8 +28,24 @@ function makeConfetti(): ConfettiPiece[] {
}));
}
/**
* Twenty pieces of confetti falling for three seconds. Vestibular-disorder
* guidance is not to animate at all when the user has asked the OS not to, so
* the overlay renders nothing under `prefers-reduced-motion: reduce` rather
* than a still frame of coloured squares. It is purely decorative - the
* completion modal carries the actual news - so nothing is lost.
*/
function prefersReducedMotion(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
export default function CelebrationOverlay() {
const [visible, setVisible] = useState(true);
const [reduceMotion] = useState(prefersReducedMotion);
// Rolled once per mount. Randomising in the render body re-rolled every piece
// on each re-render, so confetti visibly jumped mid-flight.
const [pieces] = useState(makeConfetti);
@@ -39,10 +55,13 @@ export default function CelebrationOverlay() {
return () => clearTimeout(timer);
}, []);
if (!visible) return null;
if (!visible || reduceMotion) return null;
return (
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden">
<div
className="pointer-events-none fixed inset-0 z-50 overflow-hidden"
aria-hidden="true"
>
{pieces.map((piece, i) => (
<div
key={i}
+10 -3
View File
@@ -27,19 +27,26 @@ export default function GameHeader() {
<Badge variant="secondary">Custom Game</Badge>
)}
<Badge variant="outline">Chain: {getLinkCount(chain)} links</Badge>
<div className="flex items-center gap-1.5 text-sm">
<div role="status" className="flex items-center gap-1.5 text-sm">
{passedMovieB ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
<CheckCircle2 className="h-4 w-4 text-green-500" aria-hidden="true" />
) : (
<Circle className="h-4 w-4 text-muted-foreground" />
<Circle className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
{/* The visible label reads "Passed Movie B" in both states; only the
icon and colour said which. A screen reader therefore heard the
wrong thing before B was reached. */}
<span
aria-hidden="true"
className={
passedMovieB ? 'text-green-600' : 'text-muted-foreground'
}
>
Passed Movie B
</span>
<span className="sr-only">
{passedMovieB ? 'Movie B reached' : 'Movie B not yet reached'}
</span>
</div>
<div className="ml-auto">
<Timer />
+8 -3
View File
@@ -178,7 +178,7 @@ export default function SearchAutocomplete({
};
return (
<div ref={containerRef} className="relative" role="combobox" aria-expanded={isOpen} aria-haspopup="listbox" aria-owns={listId}>
<div ref={containerRef} className="relative">
<div className="relative">
<Input
ref={inputRef}
@@ -192,9 +192,14 @@ export default function SearchAutocomplete({
setIsOpen(true);
}
}}
role="searchbox"
// ARIA 1.2 combobox: the role lives on the input, not a wrapper.
// The 1.1 wrapper pattern (role on a div, aria-owns) is what this
// was, and current screen readers announce it inconsistently.
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls={listId}
aria-controls={isOpen ? listId : undefined}
aria-activedescendant={
activeIndex >= 0 ? `${listId}-option-${activeIndex}` : undefined
}
+13 -6
View File
@@ -25,8 +25,11 @@ export default function ValidationFeedback() {
if (isValidating) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<div
role="status"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Validating connection...
</div>
);
@@ -36,9 +39,10 @@ export default function ValidationFeedback() {
return (
<div
key={validationError}
className="flex animate-shake items-center gap-2 text-sm text-destructive"
role="alert"
className="flex animate-shake items-center gap-2 text-sm text-destructive motion-reduce:animate-none"
>
<AlertCircle className="h-4 w-4" />
<AlertCircle className="h-4 w-4" aria-hidden="true" />
{validationError}
</div>
);
@@ -46,8 +50,11 @@ export default function ValidationFeedback() {
if (showCheck) {
return (
<div className="flex animate-check-flash items-center gap-2 text-sm text-green-600">
<CheckCircle2 className="h-4 w-4" />
<div
role="status"
className="flex animate-check-flash items-center gap-2 text-sm text-green-600 motion-reduce:animate-none"
>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
Connected!
</div>
);
@@ -0,0 +1,207 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The notification popover's accessibility contract. Before WP-16 it was a
* `<div>` with `<div onClick>` rows: nothing here was reachable or announced.
*/
const fetchNotifications = vi.fn();
const markRead = vi.fn();
const markAllRead = vi.fn();
const dismiss = vi.fn();
vi.mock('@/stores/notification-store', () => {
const state = {
notifications: [] as unknown[],
unreadCount: 0,
fetchNotifications: (...a: unknown[]) => fetchNotifications(...a),
markRead: (...a: unknown[]) => markRead(...a),
markAllRead: (...a: unknown[]) => markAllRead(...a),
dismiss: (...a: unknown[]) => dismiss(...a),
};
const useNotificationStore = (selector: (s: typeof state) => unknown) =>
selector(state);
useNotificationStore.setState = (patch: Partial<typeof state>) =>
Object.assign(state, patch);
return { useNotificationStore };
});
const { useNotificationStore } = await import('@/stores/notification-store');
const { default: NotificationBell } = await import('./NotificationBell');
type Store = { setState: (p: Record<string, unknown>) => void };
const store = useNotificationStore as unknown as Store;
const notification = (id: string, title: string, read = false) => ({
id,
title,
message: `${title} body`,
read,
createdAt: new Date().toISOString(),
data: {},
});
function renderBell() {
return render(
<MemoryRouter>
<NotificationBell />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
store.setState({ notifications: [], unreadCount: 0 });
});
describe('NotificationBell', () => {
it('names the trigger with the unread count and reports its popup state', async () => {
store.setState({ unreadCount: 3 });
renderBell();
const trigger = screen.getByRole('button', {
name: 'Notifications, 3 unread',
});
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
await userEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(fetchNotifications).toHaveBeenCalledTimes(1);
});
it('drops the count from the name when nothing is unread', () => {
renderBell();
expect(
screen.getByRole('button', { name: 'Notifications' }),
).toBeInTheDocument();
});
it('opens a labelled dialog that the trigger controls', async () => {
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications' });
await userEvent.click(trigger);
const dialog = screen.getByRole('dialog', { name: 'Notifications' });
expect(trigger).toHaveAttribute('aria-controls', dialog.id);
});
it('renders each notification as a keyboard-reachable button, with its actions beside it', async () => {
store.setState({
notifications: [
notification('n1', 'Alice challenged you'),
notification('n2', 'Bob accepted', true),
],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
const dialog = screen.getByRole('dialog');
const rows = within(dialog).getAllByRole('listitem');
expect(rows).toHaveLength(2);
// The row action is a button, and it is not wrapping the other buttons.
// The name is the row's full content (title, body, time) - what a sighted
// user sees - prefixed by the visually hidden unread marker. Anchored, so
// it cannot match the "Mark ... as read" / "Dismiss ..." siblings.
const rowButton = within(rows[0]).getByRole('button', {
name: /^Unread:\s*Alice challenged you/,
});
expect(rowButton.querySelector('button')).toBeNull();
// Per-row controls have real names, not just a tooltip.
expect(
within(rows[0]).getByRole('button', {
name: 'Mark "Alice challenged you" as read',
}),
).toBeInTheDocument();
expect(
within(rows[0]).getByRole('button', {
name: 'Dismiss "Alice challenged you"',
}),
).toBeInTheDocument();
// A read notification has no mark-read control and no "Unread" prefix.
expect(
within(rows[1]).queryByRole('button', { name: /Mark .* as read/ }),
).toBeNull();
expect(
within(rows[1]).getByRole('button', { name: /^Bob accepted/ }),
).toBeInTheDocument();
});
it('activates a row from the keyboard and marks it read', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you')],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
const row = screen.getByRole('button', {
name: /^Unread:\s*Alice challenged you/,
});
row.focus();
await userEvent.keyboard('{Enter}');
expect(markRead).toHaveBeenCalledWith('n1');
});
it('closes on Escape and returns focus to the trigger', async () => {
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications' });
await userEvent.click(trigger);
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Focus is somewhere inside the panel, as it would be after tabbing in.
screen.getByRole('button', { name: 'Close notifications' }).focus();
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).toBeNull();
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(trigger).toHaveFocus();
});
it('closes from its close button and returns focus to the trigger', async () => {
renderBell();
const trigger = screen.getByRole('button', { name: 'Notifications' });
await userEvent.click(trigger);
await userEvent.click(
screen.getByRole('button', { name: 'Close notifications' }),
);
expect(screen.queryByRole('dialog')).toBeNull();
expect(trigger).toHaveFocus();
});
it('exposes mark-all-read with a real name only while something is unread', async () => {
store.setState({
notifications: [notification('n1', 'Alice challenged you')],
unreadCount: 1,
});
renderBell();
await userEvent.click(screen.getByRole('button', { name: /Notifications/ }));
await userEvent.click(
screen.getByRole('button', { name: 'Mark all notifications as read' }),
);
expect(markAllRead).toHaveBeenCalledTimes(1);
});
it('hides the decorative badge and icons from assistive tech', async () => {
store.setState({ unreadCount: 5 });
renderBell();
const trigger = screen.getByRole('button', { name: /Notifications/ });
// The visual badge duplicates what the name already says.
expect(trigger.querySelector('span[aria-hidden="true"]')).toHaveTextContent(
'5',
);
expect(trigger.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
});
});
+102 -38
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useNotificationStore } from '@/stores/notification-store';
import { Bell, Check, CheckCheck, Trash2, X } from 'lucide-react';
@@ -15,9 +15,21 @@ function timeAgo(dateStr: string) {
return `${days}d ago`;
}
/**
* The notification popover.
*
* This used to be a bare `<div>` with `<div onClick>` rows: no role, no
* `aria-expanded` on the trigger, nothing focusable, no Escape. A keyboard user
* could open it and then do nothing with it; a screen reader announced the
* bell and nothing about the count or the panel. It is now a non-modal dialog
* anchored to its trigger: the trigger reports state and count, Escape closes
* and returns focus, and each notification is a real button.
*/
export default function NotificationBell() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
const navigate = useNavigate();
const notifications = useNotificationStore((s) => s.notifications);
const unreadCount = useNotificationStore((s) => s.unreadCount);
@@ -26,17 +38,26 @@ export default function NotificationBell() {
const markAllRead = useNotificationStore((s) => s.markAllRead);
const dismiss = useNotificationStore((s) => s.dismiss);
// Close on outside click
// Close on outside click, and on Escape from anywhere inside.
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
const handleToggle = () => {
@@ -46,6 +67,11 @@ export default function NotificationBell() {
setOpen(!open);
};
const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const handleNotificationClick = (notification: (typeof notifications)[0]) => {
if (!notification.read) {
markRead(notification.id);
@@ -57,40 +83,62 @@ export default function NotificationBell() {
}
};
const triggerLabel =
unreadCount > 0
? `Notifications, ${unreadCount} unread`
: 'Notifications';
return (
<div ref={ref} className="relative">
<button
ref={triggerRef}
type="button"
onClick={handleToggle}
className="relative flex items-center justify-center rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Notifications"
aria-label={triggerLabel}
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
>
<Bell className="h-4 w-4" />
<Bell className="h-4 w-4" aria-hidden="true" />
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white">
<span
aria-hidden="true"
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white"
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border border-border bg-card shadow-lg">
<div
id={panelId}
role="dialog"
aria-label="Notifications"
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border border-border bg-card shadow-lg"
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-sm font-semibold">Notifications</span>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
type="button"
onClick={markAllRead}
className="rounded p-1 text-xs text-muted-foreground hover:text-foreground"
title="Mark all read"
aria-label="Mark all notifications as read"
>
<CheckCheck className="h-3.5 w-3.5" />
<CheckCheck className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
<button
onClick={() => setOpen(false)}
type="button"
onClick={close}
className="rounded p-1 text-muted-foreground hover:text-foreground"
aria-label="Close notifications"
>
<X className="h-3.5 w-3.5" />
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
@@ -101,55 +149,71 @@ export default function NotificationBell() {
No notifications
</p>
) : (
notifications.map((n) => (
<div
<ul className="m-0 list-none p-0">
{notifications.map((n) => (
<li
key={n.id}
onClick={() => handleNotificationClick(n)}
className={cn(
'flex cursor-pointer items-start gap-2 border-b border-border px-3 py-2.5 transition-colors last:border-0 hover:bg-muted/50',
'flex items-start gap-2 border-b border-border px-3 py-2.5 transition-colors last:border-0 hover:bg-muted/50',
!n.read && 'bg-primary/5',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{/* The row's action is a button so it is reachable and
activatable from the keyboard. The per-row controls sit
beside it rather than inside it - a button inside a
button is invalid HTML and unreliable in every screen
reader. */}
<button
type="button"
onClick={() => handleNotificationClick(n)}
className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex items-center gap-1.5">
{!n.read && (
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
<>
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-full bg-primary"
/>
<span className="sr-only">Unread: </span>
</>
)}
<p className="truncate text-sm font-medium">{n.title}</p>
</div>
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
<span className="truncate text-sm font-medium">
{n.title}
</span>
</span>
<span className="mt-0.5 block text-xs text-muted-foreground line-clamp-2">
{n.message}
</p>
<p className="mt-1 text-[10px] text-muted-foreground">
</span>
<span className="mt-1 block text-[10px] text-muted-foreground">
{timeAgo(n.createdAt)}
</p>
</div>
</span>
</button>
<div className="flex shrink-0 items-center gap-0.5">
{!n.read && (
<button
onClick={(e) => {
e.stopPropagation();
markRead(n.id);
}}
type="button"
onClick={() => markRead(n.id)}
className="rounded p-1 text-muted-foreground hover:text-foreground"
title="Mark as read"
aria-label={`Mark "${n.title}" as read`}
>
<Check className="h-3 w-3" />
<Check className="h-3 w-3" aria-hidden="true" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
dismiss(n.id);
}}
type="button"
onClick={() => dismiss(n.id)}
className="rounded p-1 text-muted-foreground hover:text-destructive"
title="Dismiss"
aria-label={`Dismiss "${n.title}"`}
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-3 w-3" aria-hidden="true" />
</button>
</div>
</div>
))
</li>
))}
</ul>
)}
</div>
</div>
+13 -1
View File
@@ -42,6 +42,14 @@ export default function PageLayout({
"flex flex-col bg-gradient-to-b from-background to-background/95",
noPadding ? "h-[100dvh] overflow-hidden" : "min-h-screen",
)}>
{/* Visually hidden until focused, so keyboard and screen-reader users can
jump past the header on every page instead of tabbing through it. */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[60] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
Skip to content
</a>
<header
className={cn(
"border-b border-border bg-card/80 backdrop-blur-sm",
@@ -197,7 +205,11 @@ export default function PageLayout({
</div>
</div>
</header>
<main className={cn("mx-auto w-full max-w-4xl flex-1", noPadding ? "overflow-hidden" : "px-4 py-6")} role="main">
<main
id="main-content"
tabIndex={-1}
className={cn("mx-auto w-full max-w-4xl flex-1 outline-none", noPadding ? "overflow-hidden" : "px-4 py-6")}
>
{children}
</main>
</div>
+19
View File
@@ -188,3 +188,22 @@
cursor: pointer;
}
}
/*
* Reduced motion. When the OS-level preference is set, every animation and
* transition in the app collapses to effectively instant - Tailwind's own
* utilities, the custom keyframes above, and anything added later. A single
* rule here is more reliable than remembering a motion-reduce: variant on each
* animated element. The confetti overlay additionally does not render at all,
* since a still frame of it has no meaning.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+7 -3
View File
@@ -60,7 +60,7 @@ export default function AddFriend() {
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link to="/friends">
<Button variant="ghost" size="sm">
<Button variant="ghost" size="sm" aria-label="Back to friends">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
@@ -68,13 +68,17 @@ export default function AddFriend() {
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
aria-label="Search by username"
placeholder="Search by username..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
autoFocus
/>
</div>
+4 -2
View File
@@ -348,10 +348,11 @@ function GenerateChallengeSection() {
</h2>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-sm text-muted-foreground">
<label htmlFor="admin-challenge-date" className="mb-1 block text-sm text-muted-foreground">
Date
</label>
<Input
id="admin-challenge-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
@@ -359,10 +360,11 @@ function GenerateChallengeSection() {
/>
</div>
<div>
<label className="mb-1 block text-sm text-muted-foreground">
<label htmlFor="admin-challenge-difficulty" className="mb-1 block text-sm text-muted-foreground">
Difficulty
</label>
<select
id="admin-challenge-difficulty"
value={difficulty}
onChange={(e) => setDifficulty(e.target.value)}
className="h-9 rounded-md border border-border bg-background px-3 text-sm"
+3 -2
View File
@@ -158,7 +158,7 @@ export default function Chat() {
{/* Chat header */}
<div className="flex shrink-0 items-center gap-3 border-b border-border bg-background px-4 py-3">
<Link to="/friends">
<Button variant="ghost" size="sm">
<Button variant="ghost" size="sm" aria-label="Back to friends">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
@@ -221,6 +221,7 @@ export default function Chat() {
{/* Input */}
<form onSubmit={handleSend} className="flex shrink-0 gap-2 border-t border-border bg-background px-4 py-3">
<Input
aria-label="Message"
placeholder="Type a message..."
value={input}
onChange={(e) => {
@@ -232,7 +233,7 @@ export default function Chat() {
}}
className="flex-1"
/>
<Button type="submit" size="sm" disabled={!input.trim() || sending}>
<Button type="submit" size="sm" disabled={!input.trim() || sending} aria-label="Send message">
<Send className="h-4 w-4" />
</Button>
</form>
+4 -4
View File
@@ -76,8 +76,8 @@ export default function CustomGame() {
</div>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Movie A</label>
<div className="space-y-2" role="group" aria-labelledby="custom-game-movie-a-label">
<span id="custom-game-movie-a-label" className="text-sm font-medium">Movie A</span>
{movieA ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
{movieA.posterPath && (
@@ -111,8 +111,8 @@ export default function CustomGame() {
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Movie B</label>
<div className="space-y-2" role="group" aria-labelledby="custom-game-movie-b-label">
<span id="custom-game-movie-b-label" className="text-sm font-medium">Movie B</span>
{movieB ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
{movieB.posterPath && (
+49 -17
View File
@@ -3,6 +3,14 @@ import { Link, useNavigate } from 'react-router';
import PageLayout from '@/components/layout/PageLayout';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useAuthStore } from '@/stores/auth-store';
import { useFriendsStore } from '@/stores/friends-store';
import { useNotificationStore } from '@/stores/notification-store';
@@ -170,7 +178,13 @@ export default function Friends() {
{(friend.displayName || friend.username)[0].toUpperCase()}
</div>
{onlineFriendIds.has(friend.id) && (
<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-card bg-green-500" />
<>
<div
aria-hidden="true"
className="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-card bg-green-500"
/>
<span className="sr-only">Online</span>
</>
)}
</div>
<div>
@@ -263,16 +277,35 @@ export default function Friends() {
</div>
)}
{/* Challenge Dialog */}
{challengeDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-sm mx-4">
<CardContent className="space-y-4 pt-6">
<h3 className="text-lg font-semibold">Challenge Friend</h3>
{/* Challenge Dialog. Was a fixed <div> with no role, no focus trap and
no Escape handling, so keyboard focus tabbed straight through it into
the page behind. The Dialog primitive provides all of that. */}
<Dialog
open={challengeDialog !== null}
onOpenChange={(open) => {
if (!open) setChallengeDialog(null);
}}
>
<DialogContent className="sm:max-w-sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Challenge Friend</DialogTitle>
<DialogDescription>
Pick a difficulty and how the match is played.
</DialogDescription>
</DialogHeader>
<div>
<label className="text-sm font-medium">Difficulty</label>
<div className="mt-1 flex gap-2">
<span
id="challenge-difficulty-label"
className="text-sm font-medium"
>
Difficulty
</span>
<div
role="group"
aria-labelledby="challenge-difficulty-label"
className="mt-1 flex gap-2"
>
{['easy', 'medium', 'hard'].map((d) => (
<Button
key={d}
@@ -280,6 +313,7 @@ export default function Friends() {
size="sm"
onClick={() => setChallengeDifficulty(d)}
className="flex-1 capitalize"
aria-pressed={challengeDifficulty === d}
>
{d}
</Button>
@@ -300,7 +334,7 @@ export default function Friends() {
</label>
</div>
<div className="flex gap-2">
<DialogFooter className="flex gap-2 sm:flex-row">
<Button
variant="outline"
className="flex-1"
@@ -314,17 +348,15 @@ export default function Friends() {
disabled={sending}
>
{sending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Swords className="mr-2 h-4 w-4" />
<Swords className="mr-2 h-4 w-4" aria-hidden="true" />
)}
Send Challenge
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</PageLayout>
);
+18 -10
View File
@@ -272,7 +272,11 @@ export default function GameNight() {
<p className="text-sm font-medium">
This room requires a password
</p>
<label htmlFor="game-night-join-password" className="sr-only">
Room password
</label>
<input
id="game-night-join-password"
type="password"
value={joinPassword}
onChange={(e) => setJoinPassword(e.target.value)}
@@ -303,8 +307,8 @@ export default function GameNight() {
<div className="space-y-4">
<h2 className="text-lg font-semibold">Create Room</h2>
<div className="space-y-2">
<label className="text-sm font-medium">Movie A</label>
<div className="space-y-2" role="group" aria-labelledby="game-night-movie-a-label">
<span id="game-night-movie-a-label" className="text-sm font-medium">Movie A</span>
{movieA ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
{movieA.posterPath && (
@@ -338,8 +342,8 @@ export default function GameNight() {
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Movie B</label>
<div className="space-y-2" role="group" aria-labelledby="game-night-movie-b-label">
<span id="game-night-movie-b-label" className="text-sm font-medium">Movie B</span>
{movieB ? (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
{movieB.posterPath && (
@@ -382,10 +386,11 @@ export default function GameNight() {
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium">
<label htmlFor="game-night-room-name" className="text-sm font-medium">
Room Name (optional)
</label>
<input
id="game-night-room-name"
type="text"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
@@ -396,10 +401,11 @@ export default function GameNight() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
<label htmlFor="game-night-room-password" className="text-sm font-medium">
Password (optional)
</label>
<input
id="game-night-room-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -410,8 +416,9 @@ export default function GameNight() {
</div>
<div className="flex items-center gap-3">
<label className="text-sm font-medium">I want to play</label>
<label htmlFor="game-night-host-playing" className="text-sm font-medium">I want to play</label>
<button
id="game-night-host-playing"
type="button"
role="switch"
aria-checked={hostPlaying}
@@ -432,10 +439,11 @@ export default function GameNight() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
<label htmlFor="game-night-max-players" className="text-sm font-medium">
Max Players: {maxPlayers}
</label>
<input
id="game-night-max-players"
type="range"
min={2}
max={16}
@@ -446,8 +454,8 @@ export default function GameNight() {
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Time Limit</label>
<div className="flex flex-wrap gap-2">
<span id="game-night-time-limit-label" className="text-sm font-medium">Time Limit</span>
<div className="flex flex-wrap gap-2" role="group" aria-labelledby="game-night-time-limit-label">
{[null, 5, 10, 15, 30].map((mins) => (
<Button
key={mins ?? 'none'}
+10 -2
View File
@@ -119,8 +119,16 @@ export default function GameNightLobby() {
<div className="flex min-h-[60vh] flex-col items-center justify-center space-y-8">
{/* Countdown overlay */}
{lobbyState === 'countdown' && countdownValue !== null && (
<div className="text-center">
<div className="animate-pulse text-8xl font-black text-primary">
<div
className="text-center"
role="timer"
aria-live="assertive"
aria-atomic="true"
>
{/* A 3-2-1 that launches the game is time-critical, so it is
announced assertively; without this a screen-reader user was
dropped into a running match with no warning. */}
<div className="animate-pulse text-8xl font-black text-primary motion-reduce:animate-none">
{countdownValue}
</div>
<p className="mt-4 text-lg text-muted-foreground">Get ready...</p>
+2
View File
@@ -188,6 +188,7 @@ export default function Leaderboard() {
size="sm"
disabled={page === 1}
onClick={() => setPage(page - 1)}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -199,6 +200,7 @@ export default function Leaderboard() {
size="sm"
disabled={page === data.totalPages}
onClick={() => setPage(page + 1)}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
+8 -4
View File
@@ -50,8 +50,9 @@ export default function Login() {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium">Email</label>
<label htmlFor="login-email" className="mb-1 block text-sm font-medium">Email</label>
<Input
id="login-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -62,8 +63,9 @@ export default function Login() {
{isRegister && (
<div>
<label className="mb-1 block text-sm font-medium">Username</label>
<label htmlFor="login-username" className="mb-1 block text-sm font-medium">Username</label>
<Input
id="login-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -76,8 +78,9 @@ export default function Login() {
)}
<div>
<label className="mb-1 block text-sm font-medium">Password</label>
<label htmlFor="login-password" className="mb-1 block text-sm font-medium">Password</label>
<Input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -89,10 +92,11 @@ export default function Login() {
{isRegister && (
<div>
<label className="mb-1 block text-sm font-medium">
<label htmlFor="login-confirm-password" className="mb-1 block text-sm font-medium">
Confirm Password
</label>
<Input
id="login-confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
+18 -2
View File
@@ -146,16 +146,20 @@ export default function Profile() {
<div>
{editing ? (
<div className="flex items-center gap-2">
<label htmlFor="profile-display-name" className="sr-only">
Display name
</label>
<Input
id="profile-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Display name"
className="h-8 w-40"
/>
<Button variant="ghost" size="sm" onClick={handleSaveProfile}>
<Button variant="ghost" size="sm" onClick={handleSaveProfile} aria-label="Save display name">
<Check className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)} aria-label="Cancel editing display name">
<X className="h-4 w-4" />
</Button>
</div>
@@ -204,20 +208,32 @@ export default function Profile() {
) : (
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<h3 className="text-sm font-semibold">Change Password</h3>
<label htmlFor="profile-current-password" className="sr-only">
Current password
</label>
<Input
id="profile-current-password"
type="password"
placeholder="Current password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
<label htmlFor="profile-new-password" className="sr-only">
New password
</label>
<Input
id="profile-new-password"
type="password"
placeholder="New password (min 8 characters)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={8}
/>
<label htmlFor="profile-confirm-new-password" className="sr-only">
Confirm new password
</label>
<Input
id="profile-confirm-new-password"
type="password"
placeholder="Confirm new password"
value={confirmNewPassword}
+7
View File
@@ -245,6 +245,7 @@ export default function Versus() {
</div>
<div className="mb-4 grid grid-cols-2 gap-3">
<Input
aria-label="Lobby name (optional)"
placeholder="Lobby name (optional)"
value={lobbyName}
onChange={(e) => setLobbyName(e.target.value)}
@@ -252,6 +253,7 @@ export default function Versus() {
/>
<Input
type="password"
aria-label="Lobby password (optional)"
placeholder="Password (optional)"
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -359,6 +361,7 @@ export default function Versus() {
variant="destructive"
onClick={() => handleCancel(match.id)}
disabled={cancelling === match.id}
aria-label={`Cancel ${match.lobbyName || 'match'}`}
>
{cancelling === match.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -483,6 +486,7 @@ export default function Versus() {
size="sm"
onClick={() => handleChallenge(match.id, match)}
disabled={joining === match.id}
aria-label={`Challenge ${match.lobbyName || `${match.player1?.username ?? 'Unknown'}'s match`}`}
>
{joining === match.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -495,6 +499,7 @@ export default function Versus() {
size="sm"
onClick={() => handleJoin(match.id, match)}
disabled={joining === match.id}
aria-label={`Join ${match.lobbyName || `${match.player1?.username ?? 'Unknown'}'s match`}`}
>
{joining === match.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -524,6 +529,7 @@ export default function Versus() {
<div className="space-y-4">
<Input
type="password"
aria-label="Match password"
placeholder="Match password"
value={joinPassword}
onChange={(e) => {
@@ -539,6 +545,7 @@ export default function Versus() {
className="w-full"
onClick={handlePasswordSubmit}
disabled={!joinPassword || joining === passwordPromptMatch?.id}
aria-label={passwordPromptAction === 'challenge' ? 'Start Challenge' : 'Join Match'}
>
{joining === passwordPromptMatch?.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
+10 -2
View File
@@ -111,8 +111,16 @@ export default function VersusLobby() {
<div className="flex min-h-[60vh] flex-col items-center justify-center space-y-8">
{/* Countdown overlay */}
{lobbyState === 'countdown' && countdownValue !== null && (
<div className="text-center">
<div className="animate-pulse text-8xl font-black text-primary">
<div
className="text-center"
role="timer"
aria-live="assertive"
aria-atomic="true"
>
{/* A 3-2-1 that launches the game is time-critical, so it is
announced assertively; without this a screen-reader user was
dropped into a running match with no warning. */}
<div className="animate-pulse text-8xl font-black text-primary motion-reduce:animate-none">
{countdownValue}
</div>
<p className="mt-4 text-lg text-muted-foreground">Get ready...</p>