Skip to content

Commit b15914e

Browse files
intel352claude
andcommitted
feat: add a11y improvements to LoginPage and update tests (#4)
- Replace outer <div> with <main> landmark - Add useId() for stable element IDs (inputs, error container, heading) - Connect <label> to inputs via htmlFor/id pairs - Add aria-required="true" to required fields - Add aria-label="Login form" for named form landmark - Add aria-busy on form during async login - Add aria-disabled on submit button when loading - Wrap error area in aria-live="polite" + aria-atomic region - Add role="alert" to error message for immediate announcement - Add aria-describedby on inputs referencing error when shown - Add outlineColor for visible keyboard focus indicator - Register vitest-axe matchers in test setup file - Add 18 new accessibility tests covering: axe violations, semantic HTML landmarks, ARIA attributes, live region, keyboard navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 06b1c7a commit b15914e

3 files changed

Lines changed: 238 additions & 21 deletions

File tree

src/auth/LoginPage.test.tsx

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
22
import { render, screen } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import LoginPage from './LoginPage';
5+
import { checkA11y } from '../test/a11y';
56

67
describe('LoginPage', () => {
78
it('renders with title and subtitle', () => {
@@ -68,4 +69,181 @@ describe('LoginPage', () => {
6869
expect(screen.getByText('Signing in...')).toBeInTheDocument();
6970
resolveLogin!();
7071
});
72+
73+
// Accessibility tests
74+
75+
it('should have no accessibility violations', async () => {
76+
const { container } = render(
77+
<LoginPage
78+
title="TestApp"
79+
subtitle="Sign in to continue"
80+
onLogin={vi.fn()}
81+
/>,
82+
);
83+
await checkA11y(container);
84+
});
85+
86+
it('should have no accessibility violations when error is shown', async () => {
87+
const { container } = render(
88+
<LoginPage
89+
title="TestApp"
90+
error="Invalid credentials"
91+
onLogin={vi.fn()}
92+
/>,
93+
);
94+
await checkA11y(container);
95+
});
96+
97+
// Semantic HTML structure
98+
99+
it('wraps the page in a <main> landmark', () => {
100+
render(<LoginPage title="App" onLogin={vi.fn()} />);
101+
expect(screen.getByRole('main')).toBeInTheDocument();
102+
});
103+
104+
it('renders a <form> element for the login form', () => {
105+
render(<LoginPage title="App" onLogin={vi.fn()} />);
106+
expect(screen.getByRole('form', { name: 'Login form' })).toBeInTheDocument();
107+
});
108+
109+
it('has a heading with the app title', () => {
110+
render(<LoginPage title="MyApp" onLogin={vi.fn()} />);
111+
expect(screen.getByRole('heading', { name: 'MyApp', level: 1 })).toBeInTheDocument();
112+
});
113+
114+
// ARIA attributes
115+
116+
it('labels username input with a <label> element via htmlFor', () => {
117+
render(<LoginPage title="App" usernameLabel="Email address" onLogin={vi.fn()} />);
118+
const input = screen.getByLabelText('Email address');
119+
expect(input).toBeInTheDocument();
120+
expect(input.tagName).toBe('INPUT');
121+
});
122+
123+
it('labels password input with a <label> element via htmlFor', () => {
124+
render(<LoginPage title="App" onLogin={vi.fn()} />);
125+
const input = screen.getByLabelText('Password');
126+
expect(input).toBeInTheDocument();
127+
expect(input).toHaveAttribute('type', 'password');
128+
});
129+
130+
it('marks username input as required via aria-required', () => {
131+
render(<LoginPage title="App" onLogin={vi.fn()} />);
132+
const input = screen.getByLabelText('Username');
133+
expect(input).toHaveAttribute('aria-required', 'true');
134+
});
135+
136+
it('marks password input as required via aria-required', () => {
137+
render(<LoginPage title="App" onLogin={vi.fn()} />);
138+
const input = screen.getByLabelText('Password');
139+
expect(input).toHaveAttribute('aria-required', 'true');
140+
});
141+
142+
it('sets aria-busy on the form while loading', async () => {
143+
let resolveLogin: () => void;
144+
const loginPromise = new Promise<void>((resolve) => {
145+
resolveLogin = resolve;
146+
});
147+
const onLogin = vi.fn().mockReturnValue(loginPromise);
148+
const user = userEvent.setup();
149+
150+
render(<LoginPage title="App" onLogin={onLogin} />);
151+
152+
const form = screen.getByRole('form', { name: 'Login form' });
153+
expect(form).toHaveAttribute('aria-busy', 'false');
154+
155+
await user.type(screen.getByLabelText('Username'), 'u');
156+
await user.type(screen.getByLabelText('Password'), 'p');
157+
await user.click(screen.getByRole('button', { name: 'Sign In' }));
158+
159+
expect(form).toHaveAttribute('aria-busy', 'true');
160+
resolveLogin!();
161+
});
162+
163+
it('sets aria-disabled on button when loading', async () => {
164+
let resolveLogin: () => void;
165+
const loginPromise = new Promise<void>((resolve) => {
166+
resolveLogin = resolve;
167+
});
168+
const onLogin = vi.fn().mockReturnValue(loginPromise);
169+
const user = userEvent.setup();
170+
171+
render(<LoginPage title="App" onLogin={onLogin} />);
172+
173+
await user.type(screen.getByLabelText('Username'), 'u');
174+
await user.type(screen.getByLabelText('Password'), 'p');
175+
await user.click(screen.getByRole('button', { name: 'Sign In' }));
176+
177+
const button = screen.getByRole('button', { name: 'Signing in...' });
178+
expect(button).toHaveAttribute('aria-disabled', 'true');
179+
expect(button).toBeDisabled();
180+
resolveLogin!();
181+
});
182+
183+
// Error message announcement
184+
185+
it('renders error message container with aria-live="polite"', () => {
186+
render(<LoginPage title="App" error="Bad credentials" onLogin={vi.fn()} />);
187+
const liveRegion = screen.getByRole('status');
188+
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
189+
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
190+
});
191+
192+
it('error message has role="alert"', () => {
193+
render(<LoginPage title="App" error="Bad credentials" onLogin={vi.fn()} />);
194+
expect(screen.getByRole('alert')).toHaveTextContent('Bad credentials');
195+
});
196+
197+
it('inputs reference error element via aria-describedby when error is shown', () => {
198+
render(<LoginPage title="App" error="Oops" onLogin={vi.fn()} />);
199+
const usernameInput = screen.getByLabelText('Username');
200+
const errorEl = screen.getByRole('alert');
201+
expect(usernameInput).toHaveAttribute('aria-describedby', errorEl.id);
202+
});
203+
204+
it('inputs do not have aria-describedby when there is no error', () => {
205+
render(<LoginPage title="App" onLogin={vi.fn()} />);
206+
const usernameInput = screen.getByLabelText('Username');
207+
expect(usernameInput).not.toHaveAttribute('aria-describedby');
208+
});
209+
210+
// Keyboard navigation
211+
212+
it('can tab from username to password to submit button', async () => {
213+
const user = userEvent.setup();
214+
render(<LoginPage title="App" onLogin={vi.fn()} />);
215+
216+
const usernameInput = screen.getByLabelText('Username');
217+
const passwordInput = screen.getByLabelText('Password');
218+
const submitButton = screen.getByRole('button', { name: 'Sign In' });
219+
220+
expect(usernameInput).toHaveFocus();
221+
222+
await user.tab();
223+
expect(passwordInput).toHaveFocus();
224+
225+
await user.tab();
226+
expect(submitButton).toHaveFocus();
227+
});
228+
229+
it('submits the form when pressing Enter on the submit button', async () => {
230+
const onLogin = vi.fn().mockResolvedValue(undefined);
231+
const user = userEvent.setup();
232+
233+
render(<LoginPage title="App" onLogin={onLogin} />);
234+
235+
await user.type(screen.getByLabelText('Username'), 'testuser');
236+
await user.type(screen.getByLabelText('Password'), 'testpass');
237+
238+
await user.tab();
239+
await user.keyboard('{Enter}');
240+
241+
expect(onLogin).toHaveBeenCalledWith('testuser', 'testpass');
242+
});
243+
244+
it('submit button has type="submit"', () => {
245+
render(<LoginPage title="App" onLogin={vi.fn()} />);
246+
const button = screen.getByRole('button', { name: 'Sign In' });
247+
expect(button).toHaveAttribute('type', 'submit');
248+
});
71249
});

src/auth/LoginPage.tsx

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, type FormEvent, type CSSProperties } from 'react';
1+
import { useState, useId, type FormEvent, type CSSProperties } from 'react';
22
import { colors, baseStyles } from '../theme';
33

44
export interface LoginPageProps {
@@ -34,6 +34,11 @@ export default function LoginPage({
3434
const [password, setPassword] = useState('');
3535
const [loading, setLoading] = useState(false);
3636

37+
const titleId = useId();
38+
const usernameId = useId();
39+
const passwordId = useId();
40+
const errorId = useId();
41+
3742
async function handleSubmit(e: FormEvent) {
3843
e.preventDefault();
3944
setLoading(true);
@@ -45,7 +50,7 @@ export default function LoginPage({
4550
}
4651

4752
return (
48-
<div
53+
<main
4954
style={{
5055
...baseStyles.container,
5156
display: 'flex',
@@ -65,6 +70,7 @@ export default function LoginPage({
6570
>
6671
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
6772
<h1
73+
id={titleId}
6874
style={{
6975
fontSize: '28px',
7076
fontWeight: '700',
@@ -82,25 +88,39 @@ export default function LoginPage({
8288
)}
8389
</div>
8490

85-
{error && (
86-
<div
87-
style={{
88-
backgroundColor: `${colors.red}22`,
89-
border: `1px solid ${colors.red}`,
90-
borderRadius: '6px',
91-
padding: '10px 14px',
92-
color: colors.red,
93-
fontSize: '14px',
94-
marginBottom: '20px',
95-
}}
96-
>
97-
{error}
98-
</div>
99-
)}
91+
<div
92+
role="status"
93+
aria-live="polite"
94+
aria-atomic="true"
95+
>
96+
{error && (
97+
<div
98+
id={errorId}
99+
role="alert"
100+
style={{
101+
backgroundColor: `${colors.red}22`,
102+
border: `1px solid ${colors.red}`,
103+
borderRadius: '6px',
104+
padding: '10px 14px',
105+
color: colors.red,
106+
fontSize: '14px',
107+
marginBottom: '20px',
108+
}}
109+
>
110+
{error}
111+
</div>
112+
)}
113+
</div>
100114

101-
<form onSubmit={handleSubmit}>
115+
<form
116+
onSubmit={handleSubmit}
117+
aria-label="Login form"
118+
aria-busy={loading}
119+
noValidate
120+
>
102121
<div style={{ marginBottom: '16px' }}>
103122
<label
123+
htmlFor={usernameId}
104124
style={{
105125
display: 'block',
106126
color: colors.subtext1,
@@ -112,18 +132,25 @@ export default function LoginPage({
112132
{usernameLabel}
113133
</label>
114134
<input
135+
id={usernameId}
115136
type={usernameType}
116137
value={username}
117138
onChange={(e) => setUsername(e.target.value)}
118139
placeholder={usernamePlaceholder}
119140
autoFocus
120141
required
121-
style={baseStyles.input}
142+
aria-required="true"
143+
aria-describedby={error ? errorId : undefined}
144+
style={{
145+
...baseStyles.input,
146+
outlineColor: colors.blue,
147+
}}
122148
/>
123149
</div>
124150

125151
<div style={{ marginBottom: '24px' }}>
126152
<label
153+
htmlFor={passwordId}
127154
style={{
128155
display: 'block',
129156
color: colors.subtext1,
@@ -135,30 +162,38 @@ export default function LoginPage({
135162
Password
136163
</label>
137164
<input
165+
id={passwordId}
138166
type="password"
139167
value={password}
140168
onChange={(e) => setPassword(e.target.value)}
141169
placeholder="••••••••"
142170
required
143-
style={baseStyles.input}
171+
aria-required="true"
172+
aria-describedby={error ? errorId : undefined}
173+
style={{
174+
...baseStyles.input,
175+
outlineColor: colors.blue,
176+
}}
144177
/>
145178
</div>
146179

147180
<button
148181
type="submit"
149182
disabled={loading}
183+
aria-disabled={loading}
150184
style={{
151185
...baseStyles.button.primary,
152186
width: '100%',
153187
padding: '10px',
154188
fontSize: '15px',
155189
opacity: loading ? 0.7 : 1,
190+
outlineColor: colors.blue,
156191
}}
157192
>
158193
{loading ? 'Signing in...' : 'Sign In'}
159194
</button>
160195
</form>
161196
</div>
162-
</div>
197+
</main>
163198
);
164199
}

src/test/setup.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import '@testing-library/jest-dom';
2+
import * as axeMatchers from 'vitest-axe/matchers';
3+
import { expect } from 'vitest';
4+
5+
expect.extend(axeMatchers);
26

37
// Polyfill localStorage for test environment.
48
// Node 22+ has a built-in localStorage that conflicts with jsdom.

0 commit comments

Comments
 (0)