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 (
+
+
+
+
+ onChange(event.target.value)}
+ />
+ {hasQuery && (
+
+ )}
+
+
+ {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")}
- />
-
-
-
-
-
-
-
-
);
}