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
14 changes: 14 additions & 0 deletions docs/EndpointSearch.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 41 additions & 0 deletions src/components/EndpointSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<EndpointSearch
value=""
onChange={vi.fn()}
resultCount={2}
totalCount={2}
resultsId="endpoint-results"
/>,
);

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(
<EndpointSearch
value="forecast"
onChange={onChange}
resultCount={1}
totalCount={2}
resultsId="endpoint-results"
/>,
);

expect(screen.getByRole("status").textContent).toBe("Showing 1 of 2 endpoints");
fireEvent.click(screen.getByRole("button", { name: "Clear endpoint search" }));
expect(onChange).toHaveBeenCalledWith("");
});
});
59 changes: 59 additions & 0 deletions src/components/EndpointSearch.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="endpoint-search" role="search" aria-label="Endpoint search">
<label className="endpoint-search__label" htmlFor="endpoint-search-input">
Search endpoints
</label>
<div className="endpoint-search__control">
<Search className="endpoint-search__icon" size={18} aria-hidden="true" />
<input
id="endpoint-search-input"
className="endpoint-search__input"
type="search"
role="combobox"
aria-controls={resultsId}
aria-expanded="false"
aria-autocomplete="list"
autoComplete="off"
placeholder="Filter by endpoint, path, method, or parameter"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
{hasQuery && (
<button
type="button"
className="endpoint-search__clear"
onClick={() => onChange("")}
aria-label="Clear endpoint search"
>
<X size={16} aria-hidden="true" />
</button>
)}
</div>
<p className="endpoint-search__status" role="status" aria-live="polite">
{resultLabel}
</p>
</div>
);
}
68 changes: 68 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions src/pages/ApiDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,35 @@ describe("ApiDetailPage", () => {
within(preview).getByText(/1 endpoint.*2 request parameter/),
).toBeTruthy();
});

it("filters documentation endpoints from the search combobox", () => {
render(<ApiDetailPage />);
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(<ApiDetailPage />);
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);
});
});
Loading