Skip to content

Commit 28f197a

Browse files
intel352claude
andauthored
feat: add accessibility improvements to LoginPage (#4) (#8)
- Add ARIA labels, roles, and semantic HTML to LoginPage component - Add aria-busy, aria-disabled, and aria-live for loading states - Add useId() for stable form element ID associations - Add vitest-axe for automated accessibility testing - Add checkA11y() test helper with axe-core integration - Add 18 new accessibility-focused tests for LoginPage Closes #4 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7ae3fd1 commit 28f197a

6 files changed

Lines changed: 319 additions & 23 deletions

File tree

package-lock.json

Lines changed: 50 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"typescript-eslint": "^8.48.0",
6666
"vite": "^7.3.1",
6767
"vitest": "^4.0.18",
68+
"vitest-axe": "^0.1.0",
6869
"zustand": "^5.0.11"
6970
},
7071
"publishConfig": {

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
});

0 commit comments

Comments
 (0)