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
112 changes: 112 additions & 0 deletions src/components/HelpPopover.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import HelpPopover from "./HelpPopover";

afterEach(cleanup);

describe("HelpPopover", () => {
it("renders an info icon button with the default accessible label", () => {
render(<HelpPopover content="Helpful explanation" />);

const btn = screen.getByRole("button", { name: "Help" });
expect(btn).toBeTruthy();
});

it("renders an info icon button with a custom accessible label", () => {
render(
<HelpPopover
content="Helpful explanation"
ariaLabel="What does subscription mean?"
/>
);

const btn = screen.getByRole("button", { name: "What does subscription mean?" });
expect(btn).toBeTruthy();
});

it("shows the tooltip/popover content on hover via the Tooltip component", async () => {
render(<HelpPopover content="Helpful explanation for new users" />);

// Popover should not be visible initially
expect(screen.queryByRole("tooltip")).toBeNull();

// Hover the trigger button
const btn = screen.getByRole("button", { name: "Help" });
fireEvent.mouseEnter(btn);

// The default hover delay is 300ms, so wait for the tooltip to appear
await waitFor(() => {
expect(screen.getByRole("tooltip")).toBeTruthy();
});

expect(screen.getByText("Helpful explanation for new users")).toBeTruthy();
});

it("shows the tooltip on keyboard focus", async () => {
render(<HelpPopover content="Focus-revealed content" />);

expect(screen.queryByRole("tooltip")).toBeNull();

// Focus the trigger button
const btn = screen.getByRole("button", { name: "Help" });
fireEvent.focus(btn);

// Tooltip should appear immediately on focus (Tooltip behaviour)
await waitFor(() => {
expect(screen.getByRole("tooltip")).toBeTruthy();
});

expect(screen.getByText("Focus-revealed content")).toBeTruthy();
});

it("dismisses the tooltip when the trigger loses focus", async () => {
render(<HelpPopover content="Dismiss on blur" />);

const btn = screen.getByRole("button", { name: "Help" });
fireEvent.focus(btn);

await waitFor(() => {
expect(screen.getByRole("tooltip")).toBeTruthy();
});

fireEvent.blur(btn);

await waitFor(() => {
expect(screen.queryByRole("tooltip")).toBeNull();
});
});

it("dismisses the tooltip on Escape key press", async () => {
render(<HelpPopover content="Escape dismisses" />);

const btn = screen.getByRole("button", { name: "Help" });
fireEvent.mouseEnter(btn);

await waitFor(() => {
expect(screen.getByRole("tooltip")).toBeTruthy();
});

fireEvent.keyDown(document, { key: "Escape" });

await waitFor(() => {
expect(screen.queryByRole("tooltip")).toBeNull();
});
});

it("has a visible InfoIcon SVG inside the trigger button", () => {
const { container } = render(<HelpPopover content="Icon test" />);

const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});

it("applies the help-popover-trigger class to the icon button", () => {
render(<HelpPopover content="Class test" />);

const btn = screen.getByRole("button", { name: "Help" });
expect(btn.className).toContain("help-popover-trigger");
});
});
94 changes: 94 additions & 0 deletions src/components/HelpPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useId } from "react";
import Tooltip from "./Tooltip";
import { InfoIcon } from "./icons/InfoIcon";

/**
* HelpPopover
*
* An accessible info popover (ⓘ icon) that explains a metric or field to
* first-time users. Built on top of the existing {@link Tooltip} component
* so it inherits WCAG 2.1 AA keyboard/ touch/ screen-reader support and
* design-token colour theming.
*
* **When to use**
* Place next to a UI control or label whose meaning may not be obvious to
* first-time users — e.g. a subscription price, a rate limit, or a data
* metric.
*
* **Accessibility (WCAG 2.1 AA)**
* - The trigger button receives a unique `aria-label` passed via the
* `ariaLabel` prop (default: "Help").
* - The tooltip content is connected to the trigger via `aria-describedby`
* (handled by the inner `Tooltip`).
* - `Escape` dismisses the open popover.
* - Focus management: the icon button is keyboard-focusable and receives the
* theme's global `:focus-visible` ring.
*
* **Responsive**
* - The popover content wraps naturally; `Tooltip` caps width at 240 px with
* `max-width: calc(100vw - 32px)` so it never overflows small viewports.
* - On touch devices a long-press (≥500 ms) reveals the popover.
*
* **Theme & tokens**
* Uses `--tooltip-bg`, `--tooltip-text`, `--border-subtle` tokens defined by
* `Tooltip`, which resolve to theme-appropriate values in light and dark
* mode.
*
* @param content – The explanatory text / node shown inside the popover.
* @param ariaLabel – Accessible label for the info icon button.
* Default: "Help".
* @param hoverDelayMs – Hover delay before the popover appears.
* Default: 300 ms (avoids flashing during cursor travel).
*/

type HelpPopoverProps = {
/** Explanatory content shown inside the popover. */
content: React.ReactNode;
/**
* Accessible label for the info-icon trigger button.
* @default "Help"
*/
ariaLabel?: string;
/**
* Milliseconds to wait before showing the popover on hover.
* @default 300
*/
hoverDelayMs?: number;
};

export default function HelpPopover({
content,
ariaLabel = "Help",
hoverDelayMs = 300,
}: HelpPopoverProps): JSX.Element {
const labelId = useId();

return (
<Tooltip content={content} hoverDelayMs={hoverDelayMs}>
<button
type="button"
className="help-popover-trigger"
aria-label={ariaLabel}
aria-describedby={labelId}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
padding: 0,
border: "none",
borderRadius: "50%",
background: "transparent",
cursor: "pointer",
flexShrink: 0,
}}
>
<InfoIcon size={16} aria-hidden="true" />
<span id={labelId} className="sr-only">
{ariaLabel}
</span>
</button>
</Tooltip>
);
}
27 changes: 26 additions & 1 deletion src/components/SubscribeButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ import SubscribeButton from "./SubscribeButton";
afterEach(cleanup);

describe("SubscribeButton", () => {
it("renders an idle subscribe button with correct accessible label", () => {
it("renders an idle subscribe button with correct accessible label and help popover", () => {
render(<SubscribeButton apiName="Weather API" />);

const btn = screen.getByRole("button", { name: "Subscribe to Weather API" });
expect(btn).toBeTruthy();
expect(btn.textContent).toBe("Subscribe");

// Help popover trigger should also be present
expect(
screen.getByRole("button", { name: /Help: What does subscribing to Weather API mean\?/i })
).toBeTruthy();
});

it("shows a confirmation dialog when the subscribe button is clicked", () => {
Expand Down Expand Up @@ -74,4 +79,24 @@ describe("SubscribeButton", () => {
const cancelBtn = screen.getByRole("button", { name: "Cancel subscription" });
expect(cancelBtn.className).toContain("subscribe-button-cancel");
});

it("renders the help popover with correct subscription explanation content", async () => {
render(<SubscribeButton apiName="Weather API" />);

// The help popover should be visible in idle state
const helpBtn = screen.getByRole("button", { name: /Help: What does subscribing to Weather API mean\?/i });
expect(helpBtn).toBeTruthy();

// Hover the help button to reveal the tooltip
fireEvent.mouseEnter(helpBtn);

// Wait for the 300ms hover delay to elapse and the tooltip to appear
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toBeTruthy();

// Verify the tooltip contains the explanation text
expect(tooltip.textContent).toContain("What is");
expect(tooltip.textContent).toContain("Subscribing");
expect(tooltip.textContent).toContain("charged per successful API request");
});
});
47 changes: 41 additions & 6 deletions src/components/SubscribeButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { CheckIcon } from "./icons/CheckIcon";
import HelpPopover from "./HelpPopover";

type SubscribeStatus = "idle" | "confirming" | "subscribed";

Expand Down Expand Up @@ -118,12 +119,46 @@ export default function SubscribeButton({ apiName, onSubscribe, className }: Pro

// idle state
return (
<button
className={`secondary-button subscribe-button${className ? ` ${className}` : ""}`}
aria-label={`Subscribe to ${apiName}`}
onClick={handleSubscribeClick}
<span
className={`subscribe-idle${className ? ` ${className}` : ""}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "var(--mkt-space-sm, 4px)",
}}
>
Subscribe
</button>
<button
className="secondary-button subscribe-button"
aria-label={`Subscribe to ${apiName}`}
onClick={handleSubscribeClick}
>
Subscribe
</button>

<HelpPopover
content={
<span>
<strong style={{ display: "block", marginBottom: "0.25rem" }}>
What is "Subscribing"?
</strong>
<span style={{ display: "block" }}>
Subscribing gives you access to this API’s endpoints. You are
charged per successful API request — you only pay for what you
use.
</span>
<span
style={{
display: "block",
marginTop: "0.25rem",
opacity: 0.85,
}}
>
Your subscription can be managed or cancelled at any time.
</span>
</span>
}
ariaLabel={`Help: What does subscribing to ${apiName} mean?`}
/>
</span>
);
}
30 changes: 30 additions & 0 deletions src/components/icons/InfoIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from "react";

interface IconProps extends React.SVGProps<SVGSVGElement> {
size?: 16 | 20;
}

export function InfoIcon({ size = 16, className, ...props }: IconProps) {
const ariaHidden = props["aria-label"] ? undefined : "true";

return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden={ariaHidden}
{...props}
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
}
3 changes: 2 additions & 1 deletion src/components/icons/icons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import { render, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { TagIcon, CheckIcon, WarningIcon, BoltIcon, ClockIcon } from "./index";
import { TagIcon, CheckIcon, WarningIcon, BoltIcon, ClockIcon, InfoIcon } from "./index";

const iconComponents = [
{ name: "TagIcon", Component: TagIcon },
{ name: "CheckIcon", Component: CheckIcon },
{ name: "WarningIcon", Component: WarningIcon },
{ name: "BoltIcon", Component: BoltIcon },
{ name: "ClockIcon", Component: ClockIcon },
{ name: "InfoIcon", Component: InfoIcon },
];

describe("Theme-Aware SVG Icon Set", () => {
Expand Down
1 change: 1 addition & 0 deletions src/components/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { BoltIcon } from "./BoltIcon";
export { ClockIcon } from "./ClockIcon";
export { ChevronIcon } from "./ChevronIcon";
export { LinkIcon } from "./LinkIcon";
export { InfoIcon } from "./InfoIcon";
13 changes: 13 additions & 0 deletions src/styles/focus.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@
outline-offset: 3px;
}

/* ── HelpPopover trigger (info icon) ──────────────────────────────────── */
.help-popover-trigger {
color: var(--muted);
transition: color 180ms ease;
}

.help-popover-trigger:hover,
.help-popover-trigger:focus-visible {
color: var(--accent);
outline: 2px solid var(--accent);
outline-offset: 3px;
}

/* ── ApiDetailPage: icon buttons (Postman / Insomnia / Save) ────────────── */
.api-detail-page .icon-button:focus-visible,
.endpoint-client-buttons .icon-button:focus-visible {
Expand Down
Loading