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:
1 parent
e67692006f
commit
889cdda83f
25 files changed
+2140
-109
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+1530
-2
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -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",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user