diff --git a/src/components/LiveRegion.tsx b/src/components/LiveRegion.tsx index 4aeafba..3899bac 100644 --- a/src/components/LiveRegion.tsx +++ b/src/components/LiveRegion.tsx @@ -66,32 +66,6 @@ export default function LiveRegion({ message, assertive = false }: LiveRegionPro aria-hidden={!message ? "true" : undefined} > {message} -import { ReactNode } from "react"; - -interface LiveRegionProps { - children?: ReactNode; - "aria-live"?: "polite" | "assertive" | "off"; - role?: string; - className?: string; - id?: string; -} - -export default function LiveRegion({ - children, - "aria-live": ariaLive = "polite", - role = "status", - className = "sr-only", - id, -}: LiveRegionProps) { - return ( -
- {children}
); } diff --git a/src/focus-layer.test.ts b/src/focus-layer.test.ts index 30e157b..8fb9678 100644 --- a/src/focus-layer.test.ts +++ b/src/focus-layer.test.ts @@ -100,4 +100,71 @@ describe("@layer focus contract", () => { expect(marketplacePage).not.toMatch(/\.marketplace-filter-button:focus\s*\{/); expect(recentlyActiveRail).not.toMatch(/button:focus\s*\{/); }); + + describe("ApiDetailPage focus-visible rules", () => { + const focusCss = read("src/styles/focus.css"); + + it("defines focus-visible rules for all interactive buttons inside ApiDetailPage", () => { + expect(focusCss).toMatch(/\.api-detail-page \.primary-button:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-page \.secondary-button:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-page \.ghost-button:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-page \.icon-button:focus-visible/); + }); + + it("defines focus-visible rules for ApiDetailPage form controls", () => { + expect(focusCss).toMatch(/\.api-detail-page input\[type="range"\]:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-page select:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-page input\[type="text"\]:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-page input\[type="checkbox"\]:focus-visible/); + }); + + it("defines focus-visible rules for ApiDetailPage navigation elements", () => { + expect(focusCss).toMatch(/\.api-detail-page a:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-page \.breadcrumb a:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-tabs \[role="tab"\]:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-toc__link:focus-visible/); + }); + + it("defines focus-visible rules for ApiDetailPage tab panels", () => { + expect(focusCss).toMatch(/\.api-detail-page \[role="tabpanel"\]:focus-visible/); + }); + + it("defines focus-visible rules for ApiDetailPage hero and subscribe elements", () => { + expect(focusCss).toMatch(/\.api-detail-hero \.ghost-button:focus-visible/); + expect(focusCss).toMatch(/\.api-detail-page \.subscribe-button:focus-visible/); + }); + + it("defines focus-visible rules for the save-to-collection dialog", () => { + expect(focusCss).toMatch(/\[role="dialog"\][\s\S]*?focus-visible/); + }); + + it("defines a global catch-all rule for all ApiDetailPage elements inside @layer focus", () => { + expect(focusCss).toMatch(/@layer focus[\s\S]*?\.api-detail-page \*:focus-visible/); + }); + + it("all ApiDetailPage focus-visible rules use the accent token for the ring", () => { + expect(focusCss).toMatch(/\.api-detail-page[\s\S]*?focus-visible[\s\S]*?outline:\s*2px solid var\(--accent\)/); + }); + + it("ApiDetailPage component has no inline outline:none override on interactive elements", () => { + const apiDetail = read("src/pages/ApiDetailPage.tsx"); + expect(apiDetail).not.toMatch(/outline:\s*["']none["']/i); + }); + + it("endpoint-client-buttons use :focus-visible inside @layer focus", () => { + expect(focusCss).toMatch(/@layer focus[\s\S]*?endpoint-client-buttons[\s\S]*?focus-visible/); + }); + + it("focus-visible rules for ApiDetailPage live inside the @layer focus block", () => { + // Extract all @layer focus content and verify ApiDetailPage rules are within it + const layerMatch = focusCss.match(/@layer focus\s*\{(.*)\}/s); + expect(layerMatch).toBeTruthy(); + if (layerMatch) { + const layerContent = layerMatch[1]; + expect(layerContent).toMatch(/\.api-detail-page \*:focus-visible/); + expect(layerContent).toMatch(/\.api-detail-page \.primary-button:focus-visible/); + expect(layerContent).toMatch(/\.api-detail-page \[role="tabpanel"\]:focus-visible/); + } + }); + }); }); \ No newline at end of file diff --git a/src/pages/ApiDetailPage.test.tsx b/src/pages/ApiDetailPage.test.tsx index b6e360e..1cfc388 100644 --- a/src/pages/ApiDetailPage.test.tsx +++ b/src/pages/ApiDetailPage.test.tsx @@ -494,6 +494,121 @@ describe("ApiDetailPage", () => { expect(docPanel).toBeTruthy(); expect(docPanel.tabIndex).toBe(0); }); + + it("all interactive buttons in ApiDetailPage are keyboard-focusable", () => { + renderWithProviders(); + settleLoadingState(); + + // Buttons in the hero, CTA row, and sidebar + const focusableElements = document.querySelectorAll( + ".api-detail-page button, .api-detail-page a, .api-detail-page select, .api-detail-page input, .api-detail-page [role='tab']", + ); + expect(focusableElements.length).toBeGreaterThan(0); + + // Verify they are all focusable (not disabled) + focusableElements.forEach((el) => { + // tabIndex defaults to 0 for interactive elements unless explicitly set + const htmlEl = el as HTMLElement; + if (htmlEl.hasAttribute("disabled")) { + expect(htmlEl.getAttribute("disabled")).toBe(""); + } else { + // buttons, links, inputs are inherently focusable + expect( + htmlEl.tabIndex >= 0 || htmlEl.getAttribute("disabled") === null, + ).toBe(true); + } + }); + }); + + it("tab buttons (role='tab') are reachable via keyboard", () => { + renderWithProviders(); + settleLoadingState(); + + const tabs = screen.getAllByRole("tab"); + expect(tabs.length).toBeGreaterThanOrEqual(6); // All 6 tab items + + // Verify no tab has tabIndex={-1} (all should be reachable) + tabs.forEach((tab) => { + expect((tab as HTMLElement).tabIndex).not.toBe(-1); + }); + }); + + it("select element in reviews tab is keyboard-focusable", () => { + window.history.pushState({}, "", "/details/pay-qr"); + renderWithProviders(); + settleLoadingState(); + + fireEvent.click(screen.getByRole("tab", { name: "Reviews" })); + + const select = screen.getByLabelText("Sort by"); + expect(select).toBeTruthy(); + expect((select as HTMLElement).tabIndex).not.toBe(-1); + }); + + it("range slider in pricing tab is keyboard-focusable", () => { + renderWithProviders(); + settleLoadingState(); + + fireEvent.click(screen.getByRole("tab", { name: "Pricing" })); + + const slider = screen.getByRole("slider"); + expect(slider).toBeTruthy(); + expect((slider as HTMLElement).tabIndex).not.toBe(-1); + }); + + it("subscribe button is keyboard-focusable", () => { + renderWithProviders(); + settleLoadingState(); + + const subscribeBtn = screen.getByRole("button", { name: /subscribe/i }); + expect(subscribeBtn).toBeTruthy(); + expect((subscribeBtn as HTMLElement).tabIndex).not.toBe(-1); + }); + + it("no interactive element in ApiDetailPage carries inline outline:none that would suppress the focus ring", () => { + renderWithProviders(); + settleLoadingState(); + + // Check all interactive elements for inline outline:none + const interactiveElements = document.querySelectorAll( + "button, a, input, select, textarea, [role='tab'], [role='button'], [tabindex]:not([tabindex='-1'])", + ); + + interactiveElements.forEach((el) => { + const htmlEl = el as HTMLElement; + const inlineOutline = htmlEl.style.outline; + // Inline outline:none would suppress the :focus-visible ring + expect(inlineOutline).not.toBe("none"); + }); + }); + + it("endpoint save button popover dialog is keyboard-accessible", () => { + renderWithProviders(); + settleLoadingState(); + fireEvent.click(screen.getByRole("tab", { name: "Documentation" })); + + const saveButtons = screen.getAllByRole("button", { + name: /Save endpoint to collection/i, + }); + fireEvent.click(saveButtons[0]); + + const dialog = screen.getByRole("dialog", { + name: /Save endpoint to collection/i, + }); + expect(dialog).toBeTruthy(); + + // Dialog should be focusable + expect((dialog as HTMLElement).tabIndex).not.toBe(-1); + + // Check inputs inside dialog are focusable + const inputs = dialog.querySelectorAll("input, button"); + inputs.forEach((input) => { + const htmlInput = input as HTMLElement; + if (!htmlInput.hasAttribute("disabled")) { + expect(htmlInput.tabIndex).not.toBe(-1); + } + }); + }); }); describe("aria-live announcements", () => { diff --git a/src/pages/ApiDetailPage.tsx b/src/pages/ApiDetailPage.tsx index 659fe16..fa4c5ea 100644 --- a/src/pages/ApiDetailPage.tsx +++ b/src/pages/ApiDetailPage.tsx @@ -18,6 +18,7 @@ import { ApiDetailStickyTOC, type TocSection } from "../components/ApiDetailStic import { CheckIcon } from "../components/icons"; import { copyToClipboard, getInsomniaImportUrl, getPostmanImportUrl } from "../utils/postman"; import SubscribeButton from "../components/SubscribeButton"; +import StatusBadge, { apiStatusToVariant } from "../components/StatusBadge"; import SubscribeCTA from "./SubscribeCTA"; import { useToast } from "../components/Toast"; import { useCollections } from "../state/collectionsStore"; diff --git a/src/styles/focus.css b/src/styles/focus.css index bbe6bc8..60ecfae 100644 --- a/src/styles/focus.css +++ b/src/styles/focus.css @@ -272,52 +272,67 @@ outline-offset: 3px; box-shadow: var(--focus-ring); } -} -.marketplace-page *:focus-visible, -.api-detail-page [role="tabpanel"]:focus-visible, -.api-detail-page *:focus-visible { - outline: 2px solid var(--accent, #4e85ff); - outline-offset: 4px; -} + /* ── ApiDetailPage global catch-all ────────────────────────────── + Every interactive element inside ApiDetailPage gets a visible + focus ring via the global *:focus-visible rule in index.css, but + we reinforce the ring here at the same cascade level so that + component-level rules (above) can still win by specificity. + The 4px offset provides slightly more breathing room around + card-like containers and closely packed controls. */ + .api-detail-page *:focus-visible, + .api-detail-page [role="tabpanel"]:focus-visible { + outline: 2px solid var(--accent, #4e85ff); + outline-offset: 4px; + } -/* ── ApiUsage focus-visible overrides ────────────────────────────────── - The global @layer focus provides *:focus-visible { outline: 2px solid - var(--accent); outline-offset: 3px; } for all interactive elements. - These page-level rules ensure ApiUsage-specific controls get an - equally visible ring, including those with custom styling that might - otherwise clip or obscure the global outline. - - - .tab-button: language-tab toggle (custom button) - - .chart-bar: clickable bar-chart column - - .response-display: aria-live region that can receive focus - - .api-key-card, .test-call-form: composite containers - ────────────────────────────────────────────────────────────────────── */ -.api-usage-page .tab-button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-color: var(--accent); -} + /* ── MarketplacePage global catch-all ──────────────────────────── + Same rationale as the ApiDetailPage rule above: reinforce the + global *:focus-visible ring so that all marketplace interactive + elements are covered at the layer cascade level. */ + .marketplace-page *:focus-visible { + outline: 2px solid var(--accent, #4e85ff); + outline-offset: 4px; + } -.api-usage-page .chart-bar:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - background: var(--accent); -} + /* ── ApiUsage focus-visible overrides ──────────────────────────── + The global @layer focus provides *:focus-visible { outline: 2px solid + var(--accent); outline-offset: 3px; } for all interactive elements. + These page-level rules ensure ApiUsage-specific controls get an + equally visible ring, including those with custom styling that might + otherwise clip or obscure the global outline. + + - .tab-button: language-tab toggle (custom button) + - .chart-bar: clickable bar-chart column + - .response-display: aria-live region that can receive focus + - .api-key-card, .test-call-form: composite containers + ──────────────────────────────────────────────────────────────── */ + .api-usage-page .tab-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-color: var(--accent); + } -.api-usage-page .response-display:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; -} + .api-usage-page .chart-bar:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + background: var(--accent); + } -.api-usage-page .documentation-link a:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; -} + .api-usage-page .response-display:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + } -.api-usage-page .section-header .tabs-tab:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; + .api-usage-page .documentation-link a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; + } + + .api-usage-page .section-header .tabs-tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } } /* ── reduced-motion: suppress transition on focus for ApiUsage controls ── */