Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ by removing interactive controls and making content fully visible. (Closes #708)
| Path | Description |
| ------------------- | ----------------------------------------- |
| `/` | Landing page |
| `/login` | Sign-in page with polite aria-live status announcements (issue #530) |
| `/dashboard` | Developer dashboard |
| `/marketplace` | API marketplace |
| `/billing` | USDC deposit and settlements |
Expand Down
36 changes: 36 additions & 0 deletions docs/LoginPage-aria-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* LoginPage aria-live status announcements — issue #530 (GrantFox FWC26).
*
* Screen-reader-friendly announcement of LoginPage status changes via a
* polite aria-live region (`role="status"`, `aria-live="polite"`).
*/

## Summary

`LoginPage` announces submitting, success, and error status changes through
the shared `LiveRegion` component so assistive technology users hear feedback
without depending on the visible status banner alone.

## Files changed

| File | Change |
|---|---|
| `src/pages/LoginPage.tsx` | New mock sign-in page with polite `aria-live` status announcements |
| `src/pages/LoginPage.test.tsx` | Focused tests for live-region announcements and form status flow |
| `src/App.tsx` | Registers `/login` route and document title/description |
| `README.md` | Documents the `/login` route |

## API / visible changes

- **New route:** `/login` — mock email/password sign-in (no backend auth)
- No network/API contract changes
- Visible status banner mirrors live-region messages for sighted users
- On success, navigates to `/dashboard` after a short delay

## Accessibility

- Uses `LiveRegion` with `aria-live="polite"`, `role="status"`, `aria-atomic="true"`
- Announces: validation prompts, submitting, success, and failure (with detail)
- Submit control exposes `aria-busy` while in flight
- Focus moves to the status banner after submit for keyboard users
- Labels, `aria-invalid`, and `aria-describedby` on form fields
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ShortcutsModal } from "./components/ShortcutsModal";
import { ToastProvider } from "./components/Toast";
import { InvoiceCard } from "./pages/InvoiceCard";
import BillingHistory from "./pages/BillingHistory";
import LoginPage from "./pages/LoginPage";

type DepositStage = "input" | "approving" | "pending" | "confirmed" | "failed";
type DemoOutcome = "confirmed" | "failed";
Expand Down Expand Up @@ -127,6 +128,7 @@ const APP_ROUTES = {
serverError: "/500",
rateLimitCard: "/rate-limit",
slaCard: "/marketplace/grantfox-wave-compute/sla",
login: "/login",
} as const;

function createMockHash() {
Expand Down Expand Up @@ -285,6 +287,7 @@ function App() {
"/api-usage": "API Usage – Callora",
[APP_ROUTES.landing]: "Callora",
[APP_ROUTES.endpointSummary]: "Endpoint Summary – Callora",
[APP_ROUTES.login]: "Sign in – Callora",
};
const routeDescriptionMap: Record<string, string> = {
[APP_ROUTES.marketplace]: "Explore APIs on the Callora marketplace, discover and integrate APIs for your applications.",
Expand All @@ -294,6 +297,7 @@ function App() {
"/api-usage": "Monitor API usage, request stats, and view call history.",
[APP_ROUTES.landing]: "Callora - Programmable API Access, pay-per-call billing, and on-chain settlement.",
[APP_ROUTES.endpointSummary]: "Quick reference list of all API endpoints on Callora.",
[APP_ROUTES.login]: "Sign in to Callora to manage API usage, deposits, and marketplace listings.",
};
const currentTitle = routeTitleMap[location.pathname] ?? "Callora";
const currentDescription = routeDescriptionMap[location.pathname];
Expand Down Expand Up @@ -581,6 +585,8 @@ function App() {
element={<LandingPage onStartUsingApis={() => navigate(APP_ROUTES.marketplace)} onPublishApi={() => navigate(APP_ROUTES.publish)} />}
/>

<Route path={APP_ROUTES.login} element={<LoginPage />} />

<Route path={APP_ROUTES.publish} element={<PublishApi />} />

<Route path={APP_ROUTES.dashboard} element={<DashboardPage vaultBalance={vaultBalance} walletBalance={walletBalance} costPerCall={0.08} callsPerDay={120} openDeposit={openDeposit} />} />
Expand Down
197 changes: 197 additions & 0 deletions src/pages/LoginPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "./LoginPage";

const mockNavigate = vi.fn();

vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
};
});

vi.mock("../hooks/useDocumentTitle", () => ({
default: vi.fn(),
}));

function renderPage(props?: Partial<React.ComponentProps<typeof LoginPage>>) {
return render(
<MemoryRouter>
<LoginPage {...props} />
</MemoryRouter>,
);
}

function getLiveRegion(): HTMLElement {
return screen.getByTestId("live-region-login-status");
}

describe("LoginPage", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
cleanup();
vi.useRealTimers();
});

describe("rendering", () => {
it("renders the sign-in heading and form fields", () => {
renderPage();
expect(screen.getByRole("heading", { level: 1, name: /Sign in/i })).toBeTruthy();
expect(screen.getByLabelText(/^Email$/i)).toBeTruthy();
expect(screen.getByLabelText(/^Password$/i)).toBeTruthy();
expect(screen.getByTestId("login-submit")).toBeTruthy();
});

it("renders a polite aria-live region for status updates", () => {
renderPage();
const region = getLiveRegion();
expect(region.getAttribute("aria-live")).toBe("polite");
expect(region.getAttribute("role")).toBe("status");
expect(region.getAttribute("aria-atomic")).toBe("true");
});
});

describe("aria-live status announcements", () => {
it("announces validation when credentials are empty", async () => {
renderPage({
onSubmit: vi.fn(() => Promise.resolve()),
});

fireEvent.click(screen.getByTestId("login-submit"));

await waitFor(() => {
expect(getLiveRegion().textContent).toMatch(/Enter both email and password/i);
});
expect(screen.getByRole("alert")).toBeTruthy();
});

it("announces submitting then success on a successful sign-in", async () => {
let resolveSignIn!: () => void;
const onSubmit = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveSignIn = resolve;
}),
);

renderPage({ onSubmit });

fireEvent.change(screen.getByLabelText(/^Email$/i), {
target: { value: "dev@callora.dev" },
});
fireEvent.change(screen.getByLabelText(/^Password$/i), {
target: { value: "secret" },
});
fireEvent.click(screen.getByTestId("login-submit"));

await waitFor(() => {
expect(getLiveRegion().textContent).toMatch(/Signing in/i);
});

expect(screen.getByTestId("login-status-banner").getAttribute("data-status")).toBe(
"submitting",
);
expect(screen.getByTestId("login-submit").getAttribute("aria-busy")).toBe("true");

resolveSignIn();

await waitFor(() => {
expect(getLiveRegion().textContent).toMatch(/Sign-in successful/i);
});
expect(screen.getByTestId("login-status-banner").getAttribute("data-status")).toBe(
"success",
);
});

it("announces failure details when sign-in rejects", async () => {
const onSubmit = vi.fn(() =>
Promise.reject(new Error("Invalid credentials")),
);

renderPage({ onSubmit });

fireEvent.change(screen.getByLabelText(/^Email$/i), {
target: { value: "dev@callora.dev" },
});
fireEvent.change(screen.getByLabelText(/^Password$/i), {
target: { value: "wrong" },
});
fireEvent.click(screen.getByTestId("login-submit"));

await waitFor(() => {
expect(getLiveRegion().textContent).toMatch(/Sign-in failed/i);
});
expect(getLiveRegion().textContent).toMatch(/Invalid credentials/i);
expect(screen.getByTestId("login-status-banner").getAttribute("data-status")).toBe(
"error",
);
expect(screen.getByText("Invalid credentials")).toBeTruthy();
});
});

describe("form behavior", () => {
it("disables the submit button while submitting", async () => {
const onSubmit = vi.fn(() => new Promise<void>(() => {}));
renderPage({ onSubmit });

fireEvent.change(screen.getByLabelText(/^Email$/i), {
target: { value: "dev@callora.dev" },
});
fireEvent.change(screen.getByLabelText(/^Password$/i), {
target: { value: "secret" },
});
fireEvent.click(screen.getByTestId("login-submit"));

await waitFor(() => {
expect(screen.getByTestId("login-submit")).toBeDisabled();
});
});

it("calls onSubmit with trimmed email and password", async () => {
const onSubmit = vi.fn(() => Promise.resolve());
renderPage({ onSubmit });

fireEvent.change(screen.getByLabelText(/^Email$/i), {
target: { value: " dev@callora.dev " },
});
fireEvent.change(screen.getByLabelText(/^Password$/i), {
target: { value: "secret" },
});
fireEvent.click(screen.getByTestId("login-submit"));

await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith("dev@callora.dev", "secret");
});
});

it("navigates to the success redirect after a successful sign-in", async () => {
const onSubmit = vi.fn(() => Promise.resolve());
renderPage({ onSubmit, successRedirect: "/dashboard" });

fireEvent.change(screen.getByLabelText(/^Email$/i), {
target: { value: "dev@callora.dev" },
});
fireEvent.change(screen.getByLabelText(/^Password$/i), {
target: { value: "secret" },
});
fireEvent.click(screen.getByTestId("login-submit"));

await waitFor(() => {
expect(getLiveRegion().textContent).toMatch(/Sign-in successful/i);
});

await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/dashboard");
});
});
});
});
Loading