diff --git a/docs/EndpointSearch.md b/docs/EndpointSearch.md new file mode 100644 index 0000000..4c78433 --- /dev/null +++ b/docs/EndpointSearch.md @@ -0,0 +1,14 @@ +# Endpoint Search + +`ApiDetailPage` includes an endpoint search field in the Documentation tab. + +The search filters endpoint cards by title, path, HTTP method, group, parameter +name, parameter type, and required/optional status. It is client-side only and +does not change API requests or persisted data. + +Accessibility behavior: + +- The input is labelled `Search endpoints`. +- The input uses combobox semantics and points to the filtered endpoint results. +- A polite status message announces the current result count. +- The no-results state includes a clear-filters action. diff --git a/src/components/EndpointSearch.test.tsx b/src/components/EndpointSearch.test.tsx new file mode 100644 index 0000000..bb4a604 --- /dev/null +++ b/src/components/EndpointSearch.test.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import EndpointSearch from "./EndpointSearch"; + +describe("EndpointSearch", () => { + it("renders an accessible combobox tied to the endpoint results", () => { + render( + , + ); + + const input = screen.getByRole("combobox", { name: "Search endpoints" }); + expect(input.getAttribute("aria-controls")).toBe("endpoint-results"); + expect(input.getAttribute("aria-expanded")).toBe("false"); + expect(screen.getByRole("status").textContent).toBe("2 endpoints available"); + }); + + it("reports filtered result counts and clears the query", () => { + const onChange = vi.fn(); + render( + , + ); + + expect(screen.getByRole("status").textContent).toBe("Showing 1 of 2 endpoints"); + fireEvent.click(screen.getByRole("button", { name: "Clear endpoint search" })); + expect(onChange).toHaveBeenCalledWith(""); + }); +}); diff --git a/src/components/EndpointSearch.tsx b/src/components/EndpointSearch.tsx new file mode 100644 index 0000000..805725a --- /dev/null +++ b/src/components/EndpointSearch.tsx @@ -0,0 +1,59 @@ +import { Search, X } from "lucide-react"; + +type EndpointSearchProps = { + value: string; + onChange: (value: string) => void; + resultCount: number; + totalCount: number; + resultsId: string; +}; + +export default function EndpointSearch({ + value, + onChange, + resultCount, + totalCount, + resultsId, +}: EndpointSearchProps) { + const hasQuery = value.trim().length > 0; + const resultLabel = hasQuery + ? `Showing ${resultCount} of ${totalCount} endpoints` + : `${totalCount} endpoints available`; + + return ( +
+ +
+
+

+ {resultLabel} +

+
+ ); +} diff --git a/src/index.css b/src/index.css index 67d5bf4..b189153 100644 --- a/src/index.css +++ b/src/index.css @@ -2023,6 +2023,74 @@ code, background: var(--surface-soft); } +.endpoint-search { + display: grid; + gap: 8px; + margin-top: 16px; + margin-bottom: 16px; +} + +.endpoint-search__label { + font-size: 0.875rem; + font-weight: 600; + color: var(--text); +} + +.endpoint-search__control { + position: relative; + display: flex; + align-items: center; +} + +.endpoint-search__icon { + position: absolute; + left: 14px; + color: var(--muted); + pointer-events: none; +} + +.endpoint-search__input { + width: 100%; + min-height: 44px; + padding: 0 44px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + color: var(--text); + font: inherit; +} + +.endpoint-search__input::placeholder { + color: var(--muted); +} + +.endpoint-search__clear { + position: absolute; + right: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.endpoint-search__clear:hover, +.endpoint-search__clear:focus-visible { + color: var(--text); + background: var(--surface-soft); +} + +.endpoint-search__status { + margin: 0; + color: var(--muted); + font-size: 0.8125rem; +} + .endpoint-title-row { display: flex; align-items: center; diff --git a/src/pages/ApiDetailPage.test.tsx b/src/pages/ApiDetailPage.test.tsx index d6a8fab..bb907d7 100644 --- a/src/pages/ApiDetailPage.test.tsx +++ b/src/pages/ApiDetailPage.test.tsx @@ -78,4 +78,35 @@ describe("ApiDetailPage", () => { within(preview).getByText(/1 endpoint.*2 request parameter/), ).toBeTruthy(); }); + + it("filters documentation endpoints from the search combobox", () => { + render(); + settleLoadingState(); + + fireEvent.click(screen.getByRole("tab", { name: "Documentation" })); + fireEvent.change(screen.getByRole("combobox", { name: "Search endpoints" }), { + target: { value: "historical" }, + }); + + expect(screen.getByRole("status").textContent).toBe("Showing 1 of 2 endpoints"); + expect(screen.getAllByText("Historical Weather").length).toBeGreaterThan(0); + expect(screen.queryByText("Get Forecast")).toBeNull(); + }); + + it("shows a resettable empty state when endpoint search has no matches", () => { + render(); + settleLoadingState(); + + fireEvent.click(screen.getByRole("tab", { name: "Documentation" })); + fireEvent.change(screen.getByRole("combobox", { name: "Search endpoints" }), { + target: { value: "billing" }, + }); + + expect(screen.getByRole("heading", { name: "No endpoints match your search" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Clear all filters" })); + + expect(screen.getByRole("status").textContent).toBe("2 endpoints available"); + expect(screen.getByText("Get Forecast")).toBeTruthy(); + expect(screen.getAllByText("Historical Weather").length).toBeGreaterThan(0); + }); }); diff --git a/src/pages/ApiDetailPage.tsx b/src/pages/ApiDetailPage.tsx index 3ca57aa..3df4dd4 100644 --- a/src/pages/ApiDetailPage.tsx +++ b/src/pages/ApiDetailPage.tsx @@ -9,6 +9,7 @@ import useDocumentTitle from "../hooks/useDocumentTitle"; import { findApiById } from "../data/mockApis"; import type { Review } from "../data/mockApis"; import EmptyState from "../components/EmptyState"; +import EndpointSearch from "../components/EndpointSearch"; import { formatPrice } from "../utils/format"; import { Icons } from "../utils/icons"; import { useRecentlyViewed } from "../hooks/useRecentlyViewed"; @@ -96,11 +97,11 @@ function deriveEndpointGroupLabel(endpoint: ApiEndpoint): string { } export default function ApiDetailPage({ onBack }: Props) { - const { trackFetch } = useFetchTracker(); const [tab, setTab] = useState("overview"); const [requests, setRequests] = useState(1000); const [isLoading, setIsLoading] = useState(true); const [reviewSort, setReviewSort] = useState("newest"); + const [endpointSearch, setEndpointSearch] = useState(""); // Ordered tab definitions — single source of truth for labels and ids. const TAB_ITEMS = [ @@ -136,6 +137,30 @@ export default function ApiDetailPage({ onBack }: Props) { [api], ); + const filteredDocumentationEndpoints = useMemo(() => { + const query = endpointSearch.trim().toLowerCase(); + if (!query) return documentationEndpoints; + + return documentationEndpoints.filter((endpoint) => { + const searchable = [ + endpoint.title, + endpoint.url, + endpoint.method, + endpoint.group, + ...((endpoint.params || []).flatMap((param) => [ + param.name, + param.type, + param.required ? "required" : "optional", + ])), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return searchable.includes(query); + }); + }, [documentationEndpoints, endpointSearch]); + const endpointGroups = useMemo(() => { const groups = new Map< string, @@ -148,7 +173,7 @@ export default function ApiDetailPage({ onBack }: Props) { } >(); - documentationEndpoints.forEach((endpoint) => { + filteredDocumentationEndpoints.forEach((endpoint) => { const label = deriveEndpointGroupLabel(endpoint); const id = label .toLowerCase() @@ -192,7 +217,7 @@ export default function ApiDetailPage({ onBack }: Props) { summary: `${group.endpoints.length} endpoint${group.endpoints.length === 1 ? "" : "s"} and ${group.totalParams} request parameter${group.totalParams === 1 ? "" : "s"}.`, })) .sort((a, b) => a.label.localeCompare(b.label)); - }, [documentationEndpoints]); + }, [filteredDocumentationEndpoints]); // Simulate initial data loading with 1.5s delay (consistent with MarketplacePage) useEffect(() => { @@ -496,13 +521,14 @@ print(data)`; - -
+
+
+ setTab(nextTab as TabType)} + className="api-detail-tabs no-print" + />
)} -
- {documentationEndpoints.map((ep: ApiEndpoint) => ( -
+ + + {filteredDocumentationEndpoints.length === 0 ? ( + setEndpointSearch("")} + /> + ) : ( +
+ {filteredDocumentationEndpoints.map((ep: ApiEndpoint) => ( +
-
- ))} -
+
+ ))} +
+ )}
)} @@ -1484,30 +1530,5 @@ print(data)`; -
- - - - - {/* Accessible subscribe flow with inline confirmation (issue #287) */} - showToast(`Subscribed to ${api.name}!`, "success")} - /> -
- - - -
- - - ); }