From 7c7440cdcf796943f79e109747c99302486bf10f Mon Sep 17 00:00:00 2001 From: FiniteSkills Date: Sat, 4 Jul 2026 07:01:03 +0530 Subject: [PATCH] feat(search): add searching feedback --- README.md | 7 +- src/app/search/page.test.tsx | 240 ++++++++++++++++++++++------------- src/app/search/page.tsx | 122 +++++++++++++++--- 3 files changed, 263 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 379ff03..c405825 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/app/search/page.test.tsx b/src/app/search/page.test.tsx index 0c2b72e..838039d 100644 --- a/src/app/search/page.test.tsx +++ b/src/app/search/page.test.tsx @@ -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; +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(); @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); - 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(); + + 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(); 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(); - 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(); + 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(); }); }); diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index b7c5a67..880ccf2 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,36 +1,108 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { SearchBar } from "@/components/SearchBar"; +import { Spinner } from "@/components/Spinner"; import { apiGet } from "@/lib/apiClient"; import { useDebounce } from "@/lib/useDebounce"; -import { SearchBar } from "@/components/SearchBar"; type Service = { serviceId: string; priceStroops: number }; +type SearchState = { + items: Service[] | null; + loading: boolean; + error: string | null; +}; + +type SearchAction = + | { type: "clear" } + | { type: "loading" } + | { type: "success"; items: Service[] } + | { type: "error"; error: string }; + +function searchReducer(state: SearchState, action: SearchAction): SearchState { + switch (action.type) { + case "clear": + return { items: null, loading: false, error: null }; + case "loading": + return { ...state, loading: true, error: null }; + case "success": + return { items: action.items, loading: false, error: null }; + case "error": + return { items: null, loading: false, error: action.error }; + } +} + export default function SearchPage() { const [q, setQ] = useState(""); const debounced = useDebounce(q, 250); - const [items, setItems] = useState(null); - const visibleItems = debounced ? items : null; + const [{ items, loading, error }, dispatchSearch] = useReducer(searchReducer, { + items: null, + loading: false, + error: null, + }); + const requestId = useRef(0); + const latestInputRef = useRef(""); + const latestInput = q.trim(); + const query = debounced.trim(); + const hasPendingDebounce = latestInput !== query; + const isSearching = Boolean(latestInput) && (hasPendingDebounce || loading); + const visibleItems = query ? items : null; + + const handleQueryChange = (value: string) => { + latestInputRef.current = value.trim(); + setQ(value); + }; // Derive live region text from items and debounced query const liveRegionText = useMemo(() => { - if (!debounced) return ""; + if (!query || error || isSearching) return ""; if (!items) return ""; const count = items.length; return count === 0 - ? `No matches for "${debounced}"` - : `${count} result${count === 1 ? "" : "s"} for "${debounced}"`; - }, [debounced, items]); + ? `No matches for "${query}"` + : `${count} result${count === 1 ? "" : "s"} for "${query}"`; + }, [error, isSearching, items, query]); useEffect(() => { - if (!debounced) return; + requestId.current += 1; + const activeRequestId = requestId.current; + + if (!query) { + dispatchSearch({ type: "clear" }); + return; + } + + const controller = new AbortController(); + let cancelled = false; + dispatchSearch({ type: "loading" }); + const isCurrentRequest = () => + !cancelled && + requestId.current === activeRequestId && + latestInputRef.current === query; + apiGet<{ services: Service[] }>( - `/api/v1/services?q=${encodeURIComponent(debounced)}&limit=50` + `/api/v1/services?q=${encodeURIComponent(query)}&limit=50`, + { signal: controller.signal } ) - .then((b) => setItems(b.services)) - .catch(() => setItems([])); - }, [debounced]); + .then((b) => { + if (!isCurrentRequest()) return; + dispatchSearch({ type: "success", items: b.services }); + }) + .catch((e) => { + if (!isCurrentRequest()) return; + dispatchSearch({ + type: "error", + error: (e as Error).message || "Search failed", + }); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [query]); return (

Search

- + + {isSearching && ( +
+ + Searching... +
+ )} + {error && ( +

+ {error} +

+ )}
{liveRegionText}
- {visibleItems && visibleItems.length === 0 && ( + {!error && visibleItems && visibleItems.length === 0 && (

No matches.

)} - {visibleItems && visibleItems.length > 0 && ( + {!error && visibleItems && visibleItems.length > 0 && (
    {visibleItems.map((s) => (
  • - + {s.serviceId} - {" "} + {" "} — {s.priceStroops} stroops
  • ))}