diff --git a/apps/web/src/app/(auth)/login/page.test.tsx b/apps/web/src/app/(auth)/login/page.test.tsx index 4007c3b..8e0797e 100644 --- a/apps/web/src/app/(auth)/login/page.test.tsx +++ b/apps/web/src/app/(auth)/login/page.test.tsx @@ -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(); + 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(); + + 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(); + 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(); + 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(); + + 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 => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + 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 => { + mockSearchParams.set("error", "access_denied"); + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + 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 => { + mockSearchParams.set("error", "access_denied"); + mockFetchConfig(EMAIL_ONLY_CONFIG); + + render(); + + 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 => { + mockFetchConfig(EMAIL_ONLY_CONFIG); + const user = userEvent.setup(); + + render(); + + 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 => { + mockFetchConfig(OAUTH_ONLY_CONFIG); + + render(); + + 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 */ /* ------------------------------------------------------------------ */ diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 0ebc9f9..e978f11 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -128,19 +128,24 @@ export default function LoginPage(): ReactElement { ); return ( -
+
-

Welcome to Mosaic Stack

-

+

Welcome to Mosaic Stack

+

Your personal assistant platform. Organize tasks, events, and projects with a PDA-friendly approach.

-
+
{loadingConfig ? ( -
+