feat(#416): responsive layout + accessibility for login page
Some checks failed
ci/woodpecker/push/web Pipeline failed
Some checks failed
ci/woodpecker/push/web Pipeline failed
- Mobile-first responsive classes (p-4 sm:p-8, text-2xl sm:text-4xl) - WCAG 2.1 AA: role=status on loading spinner, aria-labels, focus management - Loading spinner has role=status and aria-label - All interactive elements keyboard-accessible - Added 10 new tests for responsive layout and accessibility Refs #416 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -283,6 +283,153 @@ describe("LoginPage", (): void => {
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Responsive layout tests */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("responsive layout", (): void => {
|
||||
it("applies mobile-first padding to main element", (): void => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
const { container } = render(<LoginPage />);
|
||||
const main = container.querySelector("main");
|
||||
|
||||
expect(main).toHaveClass("p-4", "sm:p-8");
|
||||
});
|
||||
|
||||
it("applies responsive text size to heading", (): void => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading).toHaveClass("text-2xl", "sm:text-4xl");
|
||||
});
|
||||
|
||||
it("applies responsive padding to card container", (): void => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
const { container } = render(<LoginPage />);
|
||||
const card = container.querySelector(".bg-white");
|
||||
|
||||
expect(card).toHaveClass("p-4", "sm:p-8");
|
||||
});
|
||||
|
||||
it("card container has full width with max-width constraint", (): void => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
const { container } = render(<LoginPage />);
|
||||
const wrapper = container.querySelector(".max-w-md");
|
||||
|
||||
expect(wrapper).toHaveClass("w-full", "max-w-md");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Accessibility tests */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("accessibility", (): void => {
|
||||
it("loading spinner has role=status", (): void => {
|
||||
// Never resolve fetch so it stays in loading state
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
|
||||
(global.fetch as Mock).mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const spinner = screen.getByTestId("loading-spinner");
|
||||
expect(spinner).toHaveAttribute("role", "status");
|
||||
expect(spinner).toHaveAttribute("aria-label", "Loading authentication options");
|
||||
});
|
||||
|
||||
it("form inputs have associated labels", async (): Promise<void> => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByLabelText(/email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
|
||||
expect(emailInput).toHaveAttribute("id", "login-email");
|
||||
expect(passwordInput).toHaveAttribute("id", "login-password");
|
||||
});
|
||||
|
||||
it("error banner has role=alert and aria-live", async (): Promise<void> => {
|
||||
mockSearchParams.set("error", "access_denied");
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("dismiss button has descriptive aria-label", async (): Promise<void> => {
|
||||
mockSearchParams.set("error", "access_denied");
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i });
|
||||
expect(dismissButton).toHaveAttribute("aria-label", "Dismiss");
|
||||
});
|
||||
|
||||
it("interactive elements are keyboard-accessible (tab order)", async (): Promise<void> => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// LoginForm auto-focuses the email input on mount
|
||||
expect(screen.getByLabelText(/email/i)).toHaveFocus();
|
||||
|
||||
// Tab forward through form: email -> password -> submit
|
||||
await user.tab();
|
||||
expect(screen.getByLabelText(/password/i)).toHaveFocus();
|
||||
|
||||
await user.tab();
|
||||
expect(screen.getByRole("button", { name: /continue/i })).toHaveFocus();
|
||||
|
||||
// All interactive elements are reachable via keyboard
|
||||
const oauthButton = screen.queryByRole("button", { name: /continue with/i });
|
||||
// No OAuth button in email-only config, but verify all form elements have tabindex >= 0
|
||||
expect(oauthButton).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email/i)).not.toHaveAttribute("tabindex", "-1");
|
||||
expect(screen.getByLabelText(/password/i)).not.toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
it("OAuth button has descriptive aria-label", async (): Promise<void> => {
|
||||
mockFetchConfig(OAUTH_ONLY_CONFIG);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor((): void => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /continue with authentik/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const oauthButton = screen.getByRole("button", { name: /continue with authentik/i });
|
||||
expect(oauthButton).toHaveAttribute("aria-label", "Continue with Authentik");
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* URL error param tests */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user