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 ── */