diff --git a/src/components/ApiCard.test.tsx b/src/components/ApiCard.test.tsx index 15ffa44..8c51f37 100644 --- a/src/components/ApiCard.test.tsx +++ b/src/components/ApiCard.test.tsx @@ -423,11 +423,48 @@ describe("ApiCard skeleton", () => { }); it("uses tone stellar for themed skeleton appearance", () => { - const { container } = render(); - const skeletons = container.querySelectorAll(".skeleton--stellar"); - expect(skeletons.length).toBeGreaterThan(0); + const { container } = render(); + const skeletons = container.querySelectorAll(".skeleton--stellar"); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it("uses CSS variable tokens for icon size and radius matching final card shape", () => { + const { container } = render(); + const header = container.querySelector(".api-marketplace-card-header"); + expect(header).toBeTruthy(); + // Header gap should use the CSS variable token + expect(header?.getAttribute("style")).toContain("mkt-space-lg"); + }); + + it("uses CSS variable token for price padding-right matching final card layout", () => { + const { container } = render(); + // The price column should use the CSS variable for padding-right + const priceDivs = container.querySelectorAll('[style*="padding-right"]'); + const hasVariable = Array.from(priceDivs).some( + (el) => (el as HTMLElement).style.paddingRight?.includes("mkt-card-price-padding-right") + ); + expect(hasVariable).toBe(true); + }); + + it("applies flexWrap on title row to match final card's responsive layout", () => { + const { container } = render(); + // The title row inside the body should have flexWrap + const body = container.querySelector(".api-marketplace-card-body"); + expect(body).toBeTruthy(); + const titleRow = body?.querySelector('[style*="flex-wrap"]'); + expect(titleRow).toBeTruthy(); + }); + + it("uses CSS variable token for sparkline gap matching final card", () => { + const { container } = render(); + // The sparkline section should use mkt-space-lg for gap + const sparklineSections = container.querySelectorAll('[style*="justify-content: space-between"]'); + const hasVariable = Array.from(sparklineSections).some( + (el) => (el as HTMLElement).style.gap?.includes("mkt-space-lg") + ); + expect(hasVariable).toBe(true); + }); }); -}); describe("ApiCard responsiveness", () => { const mockApi: APIItem = { diff --git a/src/components/ApiCard.tsx b/src/components/ApiCard.tsx index 5974a3b..babd7c1 100644 --- a/src/components/ApiCard.tsx +++ b/src/components/ApiCard.tsx @@ -83,17 +83,22 @@ export function ApiCardSkeleton({ density = "comfortable" }: { density?: "comfor -
- - -
-
+
+ + +
+
{!isCompact && ( -
+
@@ -103,7 +108,7 @@ export function ApiCardSkeleton({ density = "comfortable" }: { density?: "comfor
-
+
@@ -130,14 +135,15 @@ export function ApiCardSkeleton({ density = "comfortable" }: { density?: "comfor
)} - {/* Sparkline section placeholder */} + {/* Sparkline section placeholder — matches final card's marginTop, gap, and flexWrap */}
@@ -167,7 +173,7 @@ export function ApiCardSkeleton({ density = "comfortable" }: { density?: "comfor display: "flex", justifyContent: "space-between", alignItems: "center", - gap: 12, + gap: "var(--mkt-space-lg, 12px)", flexWrap: "wrap", }} > diff --git a/src/components/EndpointSearch.test.tsx b/src/components/EndpointSearch.test.tsx new file mode 100644 index 0000000..bf4f19f --- /dev/null +++ b/src/components/EndpointSearch.test.tsx @@ -0,0 +1,402 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import EndpointSearch from "./EndpointSearch"; + +// ── Mock data ───────────────────────────────────────────────────────────────── + +const MOCK_ENDPOINTS = [ + { + id: "forecast", + title: "Get Forecast", + url: "/v1/forecast", + method: "GET", + group: "Forecast", + apiName: "WeatherSim API", + }, + { + id: "history", + title: "Historical Weather", + url: "/v1/history", + method: "GET", + group: "Forecast", + apiName: "WeatherSim API", + }, + { + id: "alerts-create", + title: "Create Weather Alert", + url: "/v1/alerts", + method: "POST", + group: "Alerts", + apiName: "WeatherSim API", + }, + { + id: "payment-create", + title: "Create Payment", + url: "/v1/payments", + method: "POST", + group: "Payments", + apiName: "QuickPay", + }, + { + id: "payment-delete", + title: "Cancel Payment", + url: "/v1/payments/{id}", + method: "DELETE", + group: "Payments", + apiName: "QuickPay", + }, +]; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function renderSearch(props?: Partial>) { + return render( + + ); +} + +/** Get the search input element (inner inside the combobox). */ +function getSearchInput() { + return screen.getByPlaceholderText(/Search endpoints/i) as HTMLInputElement; +} + +/** Get the search input with a custom placeholder. */ +function getSearchInputByPlaceholder(placeholder: string) { + return screen.getByPlaceholderText(placeholder) as HTMLInputElement; +} + +/** Type text into the search input. */ +function typeQuery(text: string) { + const input = getSearchInput(); + fireEvent.change(input, { target: { value: text } }); + return input; +} + +/** Get the combobox outer div. */ +function getCombobox() { + return screen.getByRole("combobox"); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +afterEach(() => { + cleanup(); +}); + +describe("EndpointSearch", () => { + // ── Rendering ────────────────────────────────────────────────────────────── + + describe("rendering", () => { + it("renders a combobox with the search input", () => { + renderSearch(); + const combobox = getCombobox(); + expect(combobox).toBeTruthy(); + expect(combobox.getAttribute("aria-haspopup")).toBe("listbox"); + }); + + it("renders the placeholder text", () => { + renderSearch(); + const input = getSearchInput(); + expect(input.getAttribute("placeholder")).toBe("Search endpoints..."); + }); + + it("accepts a custom placeholder via props", () => { + renderSearch({ placeholder: "Find an API..." }); + const input = getSearchInputByPlaceholder("Find an API..."); + expect(input.getAttribute("placeholder")).toBe("Find an API..."); + }); + + it("renders the search icon", () => { + const { container } = renderSearch(); + const svg = container.querySelector("svg"); + expect(svg).toBeTruthy(); + }); + }); + + // ── Search & filtering ───────────────────────────────────────────────────── + + describe("search and filtering", () => { + it("shows the listbox when the query has enough characters", () => { + renderSearch(); + typeQuery("fore"); + const listbox = screen.getByRole("listbox", { name: /Filtered endpoints/i }); + expect(listbox).toBeTruthy(); + }); + + it("does NOT show the listbox when query is empty", () => { + renderSearch(); + typeQuery(""); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("does NOT show the listbox when query is below minQueryLength", () => { + renderSearch({ minQueryLength: 3 }); + typeQuery("fo"); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("shows the listbox when query meets minQueryLength", () => { + renderSearch({ minQueryLength: 2 }); + typeQuery("fo"); + const listbox = screen.getByRole("listbox", { name: /Filtered endpoints/i }); + expect(listbox).toBeTruthy(); + }); + + it("filters endpoints by title match", () => { + renderSearch(); + typeQuery("Forecast"); + const options = screen.getAllByRole("option"); + expect(options.length).toBe(2); + expect(options[0].textContent).toContain("Get Forecast"); + }); + + it("filters endpoints by URL match", () => { + renderSearch(); + typeQuery("/v1/payments"); + const options = screen.getAllByRole("option"); + expect(options.length).toBe(2); + // First result alphabetically is Cancel Payment + expect(options[0].textContent).toContain("Cancel Payment"); + expect(options[1].textContent).toContain("Create Payment"); + }); + + it("filters endpoints by group name", () => { + renderSearch(); + typeQuery("Alerts"); + const options = screen.getAllByRole("option"); + expect(options.length).toBe(1); + expect(options[0].textContent).toContain("Create Weather Alert"); + }); + + it("filters endpoints by API name", () => { + renderSearch(); + typeQuery("QuickPay"); + const options = screen.getAllByRole("option"); + expect(options.length).toBe(2); + expect(options[0].textContent).toContain("Cancel Payment"); + }); + + it("shows 'No endpoints found' when nothing matches", () => { + renderSearch(); + typeQuery("zzzzz"); + // Use getAllByText and check the listbox has the text + const listbox = screen.getByRole("listbox", { name: /Filtered endpoints/i }); + expect(listbox.textContent).toContain("No endpoints found"); + }); + + it("respects maxResults prop", () => { + renderSearch({ maxResults: 1 }); + typeQuery("GET"); + const options = screen.getAllByRole("option"); + expect(options.length).toBeLessThanOrEqual(1); + }); + }); + + // ── Selection & interaction ───────────────────────────────────────────────── + + describe("selection and interaction", () => { + it("calls onSelect with the endpoint when an option is clicked", () => { + const onSelect = vi.fn(); + renderSearch({ onSelect }); + typeQuery("Forecast"); + const option = screen.getByText("Get Forecast"); + fireEvent.click(option); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: "forecast", title: "Get Forecast" }) + ); + }); + + it("clears the query and closes the listbox after selection", async () => { + const onSelect = vi.fn(); + renderSearch({ onSelect }); + typeQuery("Forecast"); + const option = screen.getByText("Get Forecast"); + fireEvent.click(option); + const input = getSearchInput(); + expect(input.value).toBe(""); + // The listbox may re-open briefly due to focus/blur race; use waitFor + const { waitFor } = await import("@testing-library/react"); + await waitFor(() => { + expect(screen.queryByRole("listbox")).toBeNull(); + }); + }); + + it("sets aria-expanded correctly", () => { + renderSearch(); + const combobox = getCombobox(); + expect(combobox.getAttribute("aria-expanded")).toBe("false"); + typeQuery("fore"); + expect(combobox.getAttribute("aria-expanded")).toBe("true"); + const input = getSearchInput(); + fireEvent.change(input, { target: { value: "" } }); + expect(combobox.getAttribute("aria-expanded")).toBe("false"); + }); + + it("sets aria-activedescendant on keyboard navigation", () => { + renderSearch(); + typeQuery("fore"); + const input = getSearchInput(); + fireEvent.keyDown(input, { key: "ArrowDown" }); + const combobox = getCombobox(); + const descId = combobox.getAttribute("aria-activedescendant"); + expect(descId).toBeTruthy(); + expect(descId).toContain("option-0"); + }); + + it("selects the active option on Enter", () => { + const onSelect = vi.fn(); + renderSearch({ onSelect }); + typeQuery("fore"); + const input = getSearchInput(); + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it("closes the listbox on Escape", () => { + renderSearch(); + typeQuery("fore"); + expect(screen.getByRole("listbox")).toBeTruthy(); + const input = getSearchInput(); + fireEvent.keyDown(input, { key: "Escape" }); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("shows method badge text for each option", () => { + renderSearch(); + typeQuery("fore"); + const options = screen.getAllByRole("option"); + // The method badge is a nested span with monospace text + // Check that each option contains at least one uppercase method + const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + options.forEach((option) => { + const hasMethod = methods.some((m) => option.textContent?.includes(m)); + expect(hasMethod).toBe(true); + }); + }); + }); + + // ── Keyboard navigation ──────────────────────────────────────────────────── + + describe("keyboard navigation", () => { + it("ArrowDown cycles through options", () => { + renderSearch(); + typeQuery("fore"); + const input = getSearchInput(); + fireEvent.keyDown(input, { key: "ArrowDown" }); + const combobox = getCombobox(); + expect(combobox.getAttribute("aria-activedescendant")).toContain("option-0"); + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(combobox.getAttribute("aria-activedescendant")).toContain("option-1"); + }); + + it("ArrowUp moves backwards through options", () => { + renderSearch(); + typeQuery("fore"); + const input = getSearchInput(); + fireEvent.keyDown(input, { key: "ArrowUp" }); + const combobox = getCombobox(); + const lastIndex = combobox.getAttribute("aria-activedescendant"); + expect(lastIndex).toBeTruthy(); + fireEvent.keyDown(input, { key: "ArrowUp" }); + const prevIndex = combobox.getAttribute("aria-activedescendant"); + expect(prevIndex).not.toBe(lastIndex); + }); + + it("Home and End navigate to first and last options (with Ctrl/Meta)", () => { + renderSearch(); + typeQuery("fore"); + const input = getSearchInput(); + fireEvent.keyDown(input, { key: "Home", ctrlKey: true }); + const combobox = getCombobox(); + expect(combobox.getAttribute("aria-activedescendant")).toContain("option-0"); + fireEvent.keyDown(input, { key: "End", ctrlKey: true }); + const options = screen.getAllByRole("option"); + expect(combobox.getAttribute("aria-activedescendant")).toContain( + `option-${options.length - 1}` + ); + }); + }); + + // ── Accessibility ────────────────────────────────────────────────────────── + + describe("accessibility", () => { + it("combobox has aria-haspopup set to listbox", () => { + renderSearch(); + const combobox = getCombobox(); + expect(combobox.getAttribute("aria-haspopup")).toBe("listbox"); + }); + + it("combobox has aria-controls pointing to the listbox", () => { + renderSearch(); + typeQuery("fore"); + const combobox = getCombobox(); + const listbox = screen.getByRole("listbox", { name: /Filtered endpoints/i }); + expect(combobox.getAttribute("aria-controls")).toBe(listbox.id); + }); + + it("listbox renders options with role='option' and aria-selected", () => { + renderSearch(); + typeQuery("fore"); + const options = screen.getAllByRole("option"); + options.forEach((option) => { + expect(option.getAttribute("role")).toBe("option"); + expect(option.hasAttribute("aria-selected")).toBe(true); + }); + }); + + it("provides a clear button with accessible label", () => { + renderSearch(); + typeQuery("test"); + const clearBtn = screen.getByRole("button", { name: /Clear search/i }); + expect(clearBtn).toBeTruthy(); + }); + + it("clear button resets the input", async () => { + renderSearch(); + typeQuery("forecast"); + const clearBtn = screen.getByRole("button", { name: /Clear search/i }); + fireEvent.click(clearBtn); + const input = getSearchInput(); + expect(input.value).toBe(""); + const { waitFor } = await import("@testing-library/react"); + await waitFor(() => { + expect(screen.queryByRole("listbox")).toBeNull(); + }); + }); + + it("input has aria-autocomplete set to list", () => { + renderSearch(); + const input = getSearchInput(); + expect(input.getAttribute("aria-autocomplete")).toBe("list"); + }); + }); + + // ── Edge cases ───────────────────────────────────────────────────────────── + + describe("edge cases", () => { + it("handles empty endpoints array gracefully", () => { + render(); + typeQuery("test"); + const listbox = screen.getByRole("listbox", { name: /Filtered endpoints/i }); + expect(listbox.textContent).toContain("No endpoints found"); + }); + + it("does not crash when onSelect is undefined", () => { + renderSearch(); + typeQuery("forecast"); + const option = screen.getByText("Get Forecast"); + expect(() => fireEvent.click(option)).not.toThrow(); + }); + + it("handles very long query strings", () => { + renderSearch(); + typeQuery("a".repeat(100)); + const listbox = screen.queryByRole("listbox", { name: /Filtered endpoints/i }); + expect(listbox).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/EndpointSearch.tsx b/src/components/EndpointSearch.tsx new file mode 100644 index 0000000..e192c72 --- /dev/null +++ b/src/components/EndpointSearch.tsx @@ -0,0 +1,567 @@ +/** + * EndpointSearch — Accessible combobox for filtering API endpoints. + * + * GrantFox FWC26 campaign (issue #379): + * Provides a searchable combobox that filters endpoint names, URLs, methods, + * and groups in real time as the user types. + * + * Accessibility (WCAG 2.1 AA): + * - role="combobox" with aria-expanded, aria-controls, aria-activedescendant + * for the standard combobox pattern. + * - Filtered results are presented in a role="listbox" with role="option" + * children. + * - Keyboard navigation: ArrowDown/ArrowUp move focus, Enter selects, Escape + * closes the listbox. + * - Screen-reader announcements via LiveRegion for result count and selection + * changes. + * - Focus-visible ring inherits the global `--accent` focus ring from focus.css. + * + * Design-token consistency: + * - All colors reference CSS custom properties; no hardcoded hex values. + * - Dark-mode tested via ThemeProvider. + * + * Responsive: + * - The dropdown list caps its width and uses max-height + overflow-y-auto + * so it never overflows the viewport on mobile. + * - On narrow viewports (< 480 px) the input container uses a column layout. + */ + +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import LiveRegion from "./LiveRegion"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface EndpointItem { + /** Unique endpoint identifier. */ + id: string; + /** Human-readable title (e.g. "Get Forecast"). */ + title: string; + /** URL path (e.g. "/v1/forecast"). */ + url: string; + /** HTTP method (e.g. "GET", "POST"). */ + method: string; + /** Optional grouping label (e.g. "Forecast", "Alerts"). */ + group?: string; + /** Name of the parent API. */ + apiName: string; +} + +export interface EndpointSearchProps { + /** Full list of endpoints to search through. */ + endpoints: ReadonlyArray; + /** + * Called when the user selects an endpoint from the listbox. + * Receives the selected endpoint item. + */ + onSelect?: (endpoint: EndpointItem) => void; + /** Placeholder text for the search input. @default "Search endpoints..." */ + placeholder?: string; + /** + * Maximum number of results to show in the dropdown. + * @default 20 + */ + maxResults?: number; + /** + * Minimum characters required before the listbox appears. + * @default 1 + */ + minQueryLength?: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const METHOD_COLORS: Record = { + GET: "var(--method-get, #22c55e)", + POST: "var(--method-post, #3b82f6)", + PUT: "var(--method-put, #f59e0b)", + PATCH: "var(--method-patch, #a855f7)", + DELETE: "var(--method-delete, #ef4444)", +}; + +const DEFAULT_METHOD_COLOR = "var(--muted, #9ca3af)"; + +function getMethodColor(method: string): string { + return METHOD_COLORS[method.toUpperCase()] ?? DEFAULT_METHOD_COLOR; +} + +/** + * Score an endpoint item against a search query string. + * Returns a relevance score (higher = better match). + */ +function scoreEndpoint(endpoint: EndpointItem, query: string): number { + const q = query.toLowerCase(); + let score = 0; + + // Exact match on title is highest priority + if (endpoint.title.toLowerCase() === q) score += 100; + else if (endpoint.title.toLowerCase().startsWith(q)) score += 50; + else if (endpoint.title.toLowerCase().includes(q)) score += 30; + + // Match on URL + if (endpoint.url.toLowerCase().includes(q)) score += 20; + + // Match on group + if (endpoint.group?.toLowerCase().includes(q)) score += 15; + + // Match on API name + if (endpoint.apiName.toLowerCase().includes(q)) score += 10; + + // Match on method + if (endpoint.method.toLowerCase() === q) score += 5; + + return score; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function EndpointSearch({ + endpoints, + onSelect, + placeholder = "Search endpoints...", + maxResults = 20, + minQueryLength = 1, +}: EndpointSearchProps): JSX.Element { + const comboboxId = useId(); + const listboxId = useId(); + const inputRef = useRef(null); + const listboxRef = useRef(null); + + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [announcement, setAnnouncement] = useState(""); + + // Ref to prevent listbox re-opening after selection or clear + const suppressOpenRef = useRef(false); + + // ── Filtered results ────────────────────────────────────────────────────── + const filteredEndpoints = useMemo(() => { + const trimmed = query.trim(); + if (trimmed.length < minQueryLength) return []; + + const scored = endpoints + .map((ep) => ({ endpoint: ep, score: scoreEndpoint(ep, trimmed) })) + .filter(({ score }) => score > 0); + + // Sort by score descending, then alphabetically by title as tiebreaker + scored.sort((a, b) => { + const diff = b.score - a.score; + if (diff !== 0) return diff; + return a.endpoint.title.localeCompare(b.endpoint.title); + }); + + return scored.slice(0, maxResults).map(({ endpoint }) => endpoint); + }, [endpoints, query, maxResults, minQueryLength]); + + const hasResults = filteredEndpoints.length > 0; + + // ── Announce result count on every query change ─────────────────────────── + useEffect(() => { + if (query.trim().length < minQueryLength) { + setAnnouncement(""); + return; + } + const count = filteredEndpoints.length; + if (count === 0) { + setAnnouncement("No endpoints found"); + } else { + setAnnouncement(`${count} ${count === 1 ? "endpoint" : "endpoints"} found`); + } + }, [filteredEndpoints.length, query, minQueryLength]); + + // ── Reset active index when results change ──────────────────────────────── + useEffect(() => { + setActiveIndex(-1); + }, [filteredEndpoints.length]); + + // ── Scroll active option into view ──────────────────────────────────────── + useEffect(() => { + if (activeIndex < 0 || !listboxRef.current) return; + const option = listboxRef.current.querySelector( + `[data-endpoint-index="${activeIndex}"]` + ) as HTMLElement | null; + option?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + // ── Handlers ──────────────────────────────────────────────────────────────── + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + setQuery(value); + setIsOpen(value.trim().length >= minQueryLength); + }, + [minQueryLength] + ); + + const selectEndpoint = useCallback( + (index: number) => { + const ep = filteredEndpoints[index]; + if (!ep) return; + onSelect?.(ep); + suppressOpenRef.current = true; + setQuery(""); + setIsOpen(false); + setActiveIndex(-1); + setAnnouncement(`Selected ${ep.title}`); + inputRef.current?.focus(); + }, + [filteredEndpoints, onSelect] + ); + + const handleKeyDown = useCallback( + (e: ReactKeyboardEvent) => { + if (!isOpen || !hasResults) { + if (e.key === "Escape") { + setQuery(""); + setIsOpen(false); + inputRef.current?.blur(); + } + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex((prev) => + prev < filteredEndpoints.length - 1 ? prev + 1 : 0 + ); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex((prev) => + prev > 0 ? prev - 1 : filteredEndpoints.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0) { + selectEndpoint(activeIndex); + } + break; + case "Escape": + e.preventDefault(); + setIsOpen(false); + setActiveIndex(-1); + break; + case "Home": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + setActiveIndex(0); + } + break; + case "End": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + setActiveIndex(filteredEndpoints.length - 1); + } + break; + } + }, + [isOpen, hasResults, filteredEndpoints.length, activeIndex, selectEndpoint] + ); + + const handleInputFocus = useCallback(() => { + if (suppressOpenRef.current) { + suppressOpenRef.current = false; + return; + } + if (query.trim().length >= minQueryLength && hasResults) { + setIsOpen(true); + } + }, [query, minQueryLength, hasResults]); + + const handleInputBlur = useCallback(() => { + // Delay to allow click on option to register before closing + setTimeout(() => { + setIsOpen(false); + setActiveIndex(-1); + }, 150); + }, []); + + const handleOptionClick = useCallback( + (index: number) => { + selectEndpoint(index); + }, + [selectEndpoint] + ); + + const handleOptionMouseEnter = useCallback((index: number) => { + setActiveIndex(index); + }, []); + + // ── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ {/* ── Search input ────────────────────────────────────────────────── */} +
= 0 ? `${comboboxId}-option-${activeIndex}` : undefined + } + aria-label={placeholder} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + background: "var(--surface, #ffffff)", + border: `1px solid ${isOpen ? "var(--accent, #6366f1)" : "var(--line, rgba(0,0,0,0.12))"}`, + borderRadius: "8px", + padding: "8px 12px", + transition: "border-color 120ms ease", + }} + > + {/* Search icon */} + + + + + {query && ( + + )} +
+ + {/* ── Dropdown listbox ────────────────────────────────────────────── */} + {isOpen && ( +
+ {hasResults ? ( + filteredEndpoints.map((ep, index) => { + const isActive = index === activeIndex; + const optionId = `${comboboxId}-option-${index}`; + + return ( +
handleOptionClick(index)} + onMouseEnter={() => handleOptionMouseEnter(index)} + style={{ + display: "flex", + alignItems: "center", + gap: 8, + padding: "8px 12px", + cursor: "pointer", + background: isActive + ? "var(--surface-soft, rgba(0,0,0,0.04))" + : "transparent", + color: "var(--text, #111827)", + fontSize: "0.8125rem", + transition: "background 60ms ease", + }} + > + {/* Method badge */} + + {ep.method} + + + {/* Endpoint info */} +
+
+ {ep.title} +
+
+ + {ep.url} + + {ep.group && ( + + {ep.group} + + )} + + {ep.apiName} + +
+
+
+ ); + }) + ) : ( +
+ No endpoints found +
+ )} +
+ )} + + {/* ── Screen-reader announcements ─────────────────────────────────── */} + +
+ ); +} \ No newline at end of file diff --git a/src/pages/MarketplacePage.tsx b/src/pages/MarketplacePage.tsx index 4b17cf5..8173bdc 100644 --- a/src/pages/MarketplacePage.tsx +++ b/src/pages/MarketplacePage.tsx @@ -46,94 +46,6 @@ export default function MarketplacePage(): JSX.Element { () => searchParams.get("q") ?? "", ); - import React, { useState, useEffect } from 'react'; - -export interface MarketplacePageProps { - // Existing props... -} - -export const MarketplacePage: React.FC = () => { - const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [filter, setFilter] = useState('all'); - const [searchQuery, setSearchQuery] = useState(''); - - // State dedicated to screen reader announcements - const [srAnnouncement, setSrAnnouncement] = useState(''); - - // Example handler for filter or search change - const handleFilterChange = (newFilter: string) => { - setFilter(newFilter); - // Announce filter update trigger - setSrAnnouncement(`Filtering marketplace by ${newFilter}`); - }; - - // Announce results update after fetch/filter completion - useEffect(() => { - if (isLoading) { - setSrAnnouncement('Loading marketplace grants...'); - } else { - const count = items.length; - const message = count === 1 - ? 'Marketplace updated: 1 grant found.' - : `Marketplace updated: ${count} grants found.`; - - setSrAnnouncement(message); - } - }, [isLoading, items]); - - return ( -
-

Grant Marketplace

- - {/* Screen Reader Live Region */} -
- {srAnnouncement} -
- - {/* Visually Visible UI */} -
- - setSearchQuery(e.target.value)} - placeholder="Search by keyword..." - /> - - -
- - {isLoading ? ( -
Loading...
- ) : ( -
- {items.map((item) => ( -
-

{item.title}

-

{item.description}

-
- ))} -
- )} -
- ); -}; const setSearch = (v: string) => { setSearchRaw(v); setSearchParams((prev) => { diff --git a/src/setupTests.ts b/src/setupTests.ts index f093f82..a711254 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -67,3 +67,6 @@ Object.defineProperty(window, "IntersectionObserver", { configurable: true, }); +// Mock scrollIntoView for jsdom (used by EndpointSearch) +Element.prototype.scrollIntoView = vi.fn(); +