Skip to content
Merged
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
26 changes: 0 additions & 26 deletions src/components/LiveRegion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
id={id}
className={className}
role={role}
aria-live={ariaLive}
aria-atomic="true"
>
{children}
</div>
);
}
67 changes: 67 additions & 0 deletions src/focus-layer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
}
});
});
});
115 changes: 115 additions & 0 deletions src/pages/ApiDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,121 @@ describe("ApiDetailPage", () => {
expect(docPanel).toBeTruthy();
expect(docPanel.tabIndex).toBe(0);
});

it("all interactive buttons in ApiDetailPage are keyboard-focusable", () => {
renderWithProviders(<ApiDetailPage />);
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(<ApiDetailPage />);
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(<ApiDetailPage />);
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(<ApiDetailPage />);
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(<ApiDetailPage />);
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(<ApiDetailPage />);
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(<ApiDetailPage />);
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", () => {
Expand Down
1 change: 1 addition & 0 deletions src/pages/ApiDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
95 changes: 55 additions & 40 deletions src/styles/focus.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 ── */
Expand Down