Skip to content
Draft
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,18 @@ to screen readers after the debounced query settles. This ensures assistive-tech
users receive feedback about result counts without focus being stolen from the search
input. The announcement format is:
- "N results for 'query'" for one or more matches
- "No matches for 'query'" when the search returns zero results or fails
- "No matches for 'query'" when the search returns zero results
- No announcement for empty queries

The live region coordinates with the 250ms debounce timing to avoid spamming announcements
on every keystroke. The region is marked with `aria-atomic="true"` and uses the `sr-only`
class for visual hiding. This implementation satisfies
[WCAG 4.1.3 Status Messages](https://www.w3.org/WAI/WCAG21/Understanding/status-messages.html).
While the debounce window or backend request is pending, the page shows the shared
`Spinner` with a visible "Searching..." status. Requests include an `AbortSignal`
and a latest-input guard so out-of-order responses cannot replace newer results.
Backend errors are shown in a `role="alert"` message instead of being reported as
an empty result set.
Behaviour is covered by [`src/app/search/page.test.tsx`](src/app/search/page.test.tsx).

## API integration
Expand Down
240 changes: 153 additions & 87 deletions src/app/search/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,41 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import SearchPage from "./page";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { apiGet } from "@/lib/apiClient";
import SearchPage from "./page";

jest.mock("@/lib/apiClient");

jest.mock("@/lib/useDebounce", () => ({
useDebounce: jest.fn((value: string) => value),
}));

const apiGetMock = apiGet as jest.MockedFunction<typeof apiGet>;

async function advanceDebounce() {
await act(async () => {
jest.advanceTimersByTime(250);
});
}

async function flushPromises() {
await act(async () => {
await Promise.resolve();
});
}

async function enterSearchTerm(term: string) {
const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: term } });
await advanceDebounce();
await flushPromises();
}

describe("SearchPage", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});

afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});

it("renders the search heading and search bar", () => {
render(<SearchPage />);

Expand All @@ -38,138 +59,183 @@ describe("SearchPage", () => {
});

it("announces single result after search settles", async () => {
const mockServices = [
{ serviceId: "service-1", priceStroops: 100 },
];
apiGetMock.mockResolvedValue({ services: mockServices });
apiGetMock.mockResolvedValue({
services: [{ serviceId: "service-1", priceStroops: 100 }],
});

render(<SearchPage />);
await enterSearchTerm("test");

const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: "test" } });

await waitFor(() => {
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent('1 result for "test"');
});
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent('1 result for "test"');
});

it("announces multiple results after search settles", async () => {
const mockServices = [
{ serviceId: "service-1", priceStroops: 100 },
{ serviceId: "service-2", priceStroops: 200 },
{ serviceId: "service-3", priceStroops: 300 },
];
apiGetMock.mockResolvedValue({ services: mockServices });
apiGetMock.mockResolvedValue({
services: [
{ serviceId: "service-1", priceStroops: 100 },
{ serviceId: "service-2", priceStroops: 200 },
{ serviceId: "service-3", priceStroops: 300 },
],
});

render(<SearchPage />);
await enterSearchTerm("api");

const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: "api" } });

await waitFor(() => {
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent('3 results for "api"');
});
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent('3 results for "api"');
});

it("announces no matches when search returns empty results", async () => {
apiGetMock.mockResolvedValue({ services: [] });

render(<SearchPage />);
await enterSearchTerm("nonexistent");

const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: "nonexistent" } });

await waitFor(() => {
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent('No matches for "nonexistent"');
});
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent('No matches for "nonexistent"');
expect(screen.getByText("No matches.")).toBeInTheDocument();
});

it("announces no matches when API call fails", async () => {
it("surfaces an alert when the API call fails", async () => {
apiGetMock.mockRejectedValue(new Error("API error"));

render(<SearchPage />);
await enterSearchTerm("error");

const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: "error" } });

await waitFor(() => {
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent('No matches for "error"');
});
expect(screen.getByRole("alert")).toHaveTextContent("API error");
expect(screen.queryByText("No matches.")).not.toBeInTheDocument();
});

it("clears live region when query is cleared", async () => {
const mockServices = [{ serviceId: "service-1", priceStroops: 100 }];
apiGetMock.mockResolvedValue({ services: mockServices });
it("clears live region and results when query is cleared", async () => {
apiGetMock.mockResolvedValue({
services: [{ serviceId: "service-1", priceStroops: 100 }],
});

render(<SearchPage />);
await enterSearchTerm("test");

const searchInput = screen.getByRole("searchbox", { name: /Search/i });

// Type a search
fireEvent.change(searchInput, { target: { value: "test" } });

await waitFor(() => {
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent('1 result for "test"');
});
expect(screen.getByText("service-1")).toBeInTheDocument();

// Clear the search
const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: "" } });
await advanceDebounce();

await waitFor(() => {
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent("");
});
const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveTextContent("");
expect(screen.queryByText("service-1")).not.toBeInTheDocument();
expect(screen.queryByText("No matches.")).not.toBeInTheDocument();
});

it("renders result links when results are found", async () => {
const mockServices = [
{ serviceId: "service-1", priceStroops: 100 },
{ serviceId: "service-2", priceStroops: 200 },
];
apiGetMock.mockResolvedValue({ services: mockServices });
apiGetMock.mockResolvedValue({
services: [
{ serviceId: "service-1", priceStroops: 100 },
{ serviceId: "service-2", priceStroops: 200 },
],
});

render(<SearchPage />);
await enterSearchTerm("test");

expect(screen.getByRole("link", { name: "service-1" })).toHaveAttribute(
"href",
"/services/service-1"
);
expect(screen.getByRole("link", { name: "service-2" })).toHaveAttribute(
"href",
"/services/service-2"
);
expect(screen.getAllByText(/stroops/i)).toHaveLength(2);
});

const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: "test" } });
it("does not render results or no matches message when query is empty", () => {
render(<SearchPage />);

await waitFor(() => {
expect(screen.getByText("service-1")).toBeInTheDocument();
expect(screen.getByText("service-2")).toBeInTheDocument();
// Check that stroops text appears (price display)
expect(screen.getAllByText(/stroops/i)).toHaveLength(2);
});
expect(screen.queryByText("No matches.")).not.toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});

it("renders no matches message when results are empty", async () => {
apiGetMock.mockResolvedValue({ services: [] });
it("live region is visually hidden but accessible to screen readers", () => {
render(<SearchPage />);

const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveClass("sr-only");
});

it("shows searching feedback while debounce or fetch is pending", async () => {
let resolveSearch: (value: { services: [] }) => void = () => {};
apiGetMock.mockReturnValueOnce(
new Promise((resolve) => {
resolveSearch = resolve;
})
);

render(<SearchPage />);

const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: "empty" } });
fireEvent.change(searchInput, { target: { value: "billing" } });

expect(screen.getByText("Searching...")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Searching services");
expect(apiGetMock).not.toHaveBeenCalled();

await advanceDebounce();

expect(apiGetMock).toHaveBeenCalledWith(
"/api/v1/services?q=billing&limit=50",
expect.objectContaining({ signal: expect.any(Object) })
);
expect(screen.getByText("Searching...")).toBeInTheDocument();

await act(async () => {
resolveSearch({ services: [] });
});

await waitFor(() => {
expect(screen.getByText("No matches.")).toBeInTheDocument();
expect(screen.queryByText("Searching...")).not.toBeInTheDocument();
});
});

it("does not render results or no matches message when query is empty", () => {
it("ignores stale responses after the input changes", async () => {
type SearchPayload = {
services: { serviceId: string; priceStroops: number }[];
};
let resolveSlow: (value: SearchPayload) => void = () => {};
let resolveFast: (value: SearchPayload) => void = () => {};

apiGetMock.mockImplementation((path) => {
if (String(path).includes("slow")) {
return new Promise((resolve) => {
resolveSlow = resolve;
});
}
return new Promise((resolve) => {
resolveFast = resolve;
});
});

render(<SearchPage />);

expect(screen.queryByText("No matches.")).not.toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
const searchInput = screen.getByRole("searchbox", { name: /Search/i });
fireEvent.change(searchInput, { target: { value: "slow" } });
await advanceDebounce();

it("live region is visually hidden but accessible to screen readers", () => {
render(<SearchPage />);
fireEvent.change(searchInput, { target: { value: "fast" } });

const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveClass("sr-only");
await act(async () => {
resolveSlow({ services: [{ serviceId: "slow-service", priceStroops: 100 }] });
});

expect(screen.queryByText("slow-service")).not.toBeInTheDocument();

await advanceDebounce();

await act(async () => {
resolveFast({ services: [{ serviceId: "fast-service", priceStroops: 200 }] });
});

expect(screen.getByText("fast-service")).toBeInTheDocument();
expect(screen.queryByText("slow-service")).not.toBeInTheDocument();
});
});
Loading