From 801fefb6c89c2b3c686c0a905d700f0905ce9c42 Mon Sep 17 00:00:00 2001 From: BigMick03 Date: Tue, 28 Jul 2026 12:52:59 +0000 Subject: [PATCH] Add contextual help popover on SubscribeButton --- src/components/HelpPopover.test.tsx | 112 ++++++++++++++++++++++++ src/components/HelpPopover.tsx | 94 ++++++++++++++++++++ src/components/SubscribeButton.test.tsx | 27 +++++- src/components/SubscribeButton.tsx | 47 ++++++++-- src/components/icons/InfoIcon.tsx | 30 +++++++ src/components/icons/icons.test.tsx | 3 +- src/components/icons/index.tsx | 1 + src/styles/focus.css | 13 +++ tsconfig.tsbuildinfo | 2 +- 9 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 src/components/HelpPopover.test.tsx create mode 100644 src/components/HelpPopover.tsx create mode 100644 src/components/icons/InfoIcon.tsx diff --git a/src/components/HelpPopover.test.tsx b/src/components/HelpPopover.test.tsx new file mode 100644 index 0000000..5d34028 --- /dev/null +++ b/src/components/HelpPopover.test.tsx @@ -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(); + + const btn = screen.getByRole("button", { name: "Help" }); + expect(btn).toBeTruthy(); + }); + + it("renders an info icon button with a custom accessible label", () => { + render( + + ); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const btn = screen.getByRole("button", { name: "Help" }); + expect(btn.className).toContain("help-popover-trigger"); + }); +}); diff --git a/src/components/HelpPopover.tsx b/src/components/HelpPopover.tsx new file mode 100644 index 0000000..601beed --- /dev/null +++ b/src/components/HelpPopover.tsx @@ -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 ( + + + + ); +} diff --git a/src/components/SubscribeButton.test.tsx b/src/components/SubscribeButton.test.tsx index 0f83f50..357b13d 100644 --- a/src/components/SubscribeButton.test.tsx +++ b/src/components/SubscribeButton.test.tsx @@ -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(); 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", () => { @@ -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(); + + // 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"); + }); }); diff --git a/src/components/SubscribeButton.tsx b/src/components/SubscribeButton.tsx index 2587d19..907f792 100644 --- a/src/components/SubscribeButton.tsx +++ b/src/components/SubscribeButton.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from "react"; import { CheckIcon } from "./icons/CheckIcon"; +import HelpPopover from "./HelpPopover"; type SubscribeStatus = "idle" | "confirming" | "subscribed"; @@ -118,12 +119,46 @@ export default function SubscribeButton({ apiName, onSubscribe, className }: Pro // idle state return ( - + + + + + What is "Subscribing"? + + + Subscribing gives you access to this API’s endpoints. You are + charged per successful API request — you only pay for what you + use. + + + Your subscription can be managed or cancelled at any time. + + + } + ariaLabel={`Help: What does subscribing to ${apiName} mean?`} + /> + ); } diff --git a/src/components/icons/InfoIcon.tsx b/src/components/icons/InfoIcon.tsx new file mode 100644 index 0000000..d9573c0 --- /dev/null +++ b/src/components/icons/InfoIcon.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +interface IconProps extends React.SVGProps { + size?: 16 | 20; +} + +export function InfoIcon({ size = 16, className, ...props }: IconProps) { + const ariaHidden = props["aria-label"] ? undefined : "true"; + + return ( + + + + + + ); +} diff --git a/src/components/icons/icons.test.tsx b/src/components/icons/icons.test.tsx index b7206e5..fa38ec6 100644 --- a/src/components/icons/icons.test.tsx +++ b/src/components/icons/icons.test.tsx @@ -2,7 +2,7 @@ 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 }, @@ -10,6 +10,7 @@ const iconComponents = [ { name: "WarningIcon", Component: WarningIcon }, { name: "BoltIcon", Component: BoltIcon }, { name: "ClockIcon", Component: ClockIcon }, + { name: "InfoIcon", Component: InfoIcon }, ]; describe("Theme-Aware SVG Icon Set", () => { diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 5591c74..abed065 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -5,3 +5,4 @@ export { BoltIcon } from "./BoltIcon"; export { ClockIcon } from "./ClockIcon"; export { ChevronIcon } from "./ChevronIcon"; export { LinkIcon } from "./LinkIcon"; +export { InfoIcon } from "./InfoIcon"; diff --git a/src/styles/focus.css b/src/styles/focus.css index bbe6bc8..6b4eadc 100644 --- a/src/styles/focus.css +++ b/src/styles/focus.css @@ -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 { diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 090286d..7f3bdb8 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/ThemeContext.tsx","./src/ThemeToggle.tsx","./src/main.tsx","./src/setupTests.ts","./src/vite-env.d.ts","./src/components/ActiveFilterChips.tsx","./src/components/ApiCard.tsx","./src/components/ApiCardHoverPreview.tsx","./src/components/ApiDetailStickyTOC.tsx","./src/components/Breadcrumb.tsx","./src/components/CallHistoryRow.tsx","./src/components/CallsHeatmap.tsx","./src/components/CategoryPills.tsx","./src/components/CodeExample.tsx","./src/components/CollectionsMenu.tsx","./src/components/CommandPalette.tsx","./src/components/CompareDrawer.tsx","./src/components/CompareTray.tsx","./src/components/ContextMenu.tsx","./src/components/CopyCurlButton.tsx","./src/components/Dashboard.tsx","./src/components/DateRangePicker.tsx","./src/components/DepositPreview.tsx","./src/components/Dropdown.tsx","./src/components/EmbedPreview.tsx","./src/components/EmptyState.tsx","./src/components/EndpointGroupHover.tsx","./src/components/EndpointPreview.tsx","./src/components/ExternalLink.tsx","./src/components/FiltersBottomSheet.tsx","./src/components/FiltersSidebar.tsx","./src/components/FormField.tsx","./src/components/HealthTimeline.tsx","./src/components/JsonViewer.tsx","./src/components/KbdHint.tsx","./src/components/LiveRegion.tsx","./src/components/LowBalanceBanner.tsx","./src/components/MethodChip.tsx","./src/components/NotFound.tsx","./src/components/OpenAPIImport.tsx","./src/components/Pagination.tsx","./src/components/ParamsBuilder.tsx","./src/components/PlanBadge.tsx","./src/components/PlanNudge.tsx","./src/components/PricingTierTable.tsx","./src/components/RatingHistogram.tsx","./src/components/RecentlyActiveRail.tsx","./src/components/RelatedApisRail.tsx","./src/components/RequestBodyEditor.tsx","./src/components/RequestHistoryPanel.tsx","./src/components/RouteProgressBar.tsx","./src/components/RunExampleButton.tsx","./src/components/SearchBar.tsx","./src/components/ServerError.tsx","./src/components/ServerErrorDemo.tsx","./src/components/ShortcutsModal.tsx","./src/components/Skeleton.tsx","./src/components/SortDropdown.tsx","./src/components/Sparkline.tsx","./src/components/StarRating.tsx","./src/components/StatusBadge.tsx","./src/components/SubscribeButton.tsx","./src/components/Tabs.tsx","./src/components/TagChip.tsx","./src/components/TestInBrowser.tsx","./src/components/Toast.tsx","./src/components/TokenEditor.tsx","./src/components/Tooltip.tsx","./src/components/TopProgressBar.tsx","./src/components/UsageGauge.tsx","./src/components/WhyApi.tsx","./src/components/icons/BoltIcon.tsx","./src/components/icons/CheckIcon.tsx","./src/components/icons/ChevronIcon.tsx","./src/components/icons/ClockIcon.tsx","./src/components/icons/LinkIcon.tsx","./src/components/icons/TagIcon.tsx","./src/components/icons/WarningIcon.tsx","./src/components/icons/index.tsx","./src/config/constants.ts","./src/data/mockApis.ts","./src/hooks/useDebounce.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useFavorites.ts","./src/hooks/useFetchTracker.tsx","./src/hooks/useGlobalShortcuts.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePersistedState.ts","./src/hooks/useQuota.ts","./src/hooks/useRouteLoading.ts","./src/pages/A11yAudit.tsx","./src/pages/ApiDetailPage.skeleton.tsx","./src/pages/ApiDetailPage.tsx","./src/pages/ApiReviews.tsx","./src/pages/ApiTagFilter.tsx","./src/pages/ApiUsage.tsx","./src/pages/CallHistoryRow.tsx","./src/pages/DashboardPage.tsx","./src/pages/DesignSystemDocs.tsx","./src/pages/EndpointSummary.tsx","./src/pages/FiltersSidebar.tsx","./src/pages/InvoiceCard.tsx","./src/pages/MarketplacePage.skeleton.tsx","./src/pages/MarketplacePage.tsx","./src/pages/MyApis.tsx","./src/pages/PlanBadge.tsx","./src/pages/PricingTable.tsx","./src/pages/PublishApi.tsx","./src/pages/QuotaBanner.tsx","./src/pages/RateLimitCard.tsx","./src/pages/ReviewsTab.tsx","./src/pages/RiskGauge.tsx","./src/pages/SearchInput.tsx","./src/pages/StickyToc.tsx","./src/pages/SubscribeCTA.tsx","./src/pages/ThemePlayground.tsx","./src/state/collectionsStore.tsx","./src/state/compareStore.ts","./src/state/pinnedApis.ts","./src/state/testCallHistory.ts","./src/state/uiPrefs.ts","./src/state/userPrefs.ts","./src/utils/colorFromId.ts","./src/utils/density.ts","./src/utils/diff.ts","./src/utils/format.ts","./src/utils/icons.ts","./src/utils/openapi-parse.ts","./src/utils/postman.ts","./src/utils/schema-validate.ts","./src/utils/snapshotUrl.ts","./src/utils/toCurl.ts","./src/utils/userPrefs.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/ThemeContext.tsx","./src/ThemeToggle.tsx","./src/main.tsx","./src/setupTests.ts","./src/vite-env.d.ts","./src/components/ActiveFilterChips.tsx","./src/components/ApiCard.tsx","./src/components/ApiCardHoverPreview.tsx","./src/components/ApiDetailStickyTOC.tsx","./src/components/Breadcrumb.tsx","./src/components/CallHistoryRow.tsx","./src/components/CallsHeatmap.tsx","./src/components/CategoryPills.tsx","./src/components/CodeExample.tsx","./src/components/CollectionsMenu.tsx","./src/components/CommandPalette.tsx","./src/components/CompareDrawer.tsx","./src/components/CompareTray.tsx","./src/components/ContextMenu.tsx","./src/components/CopyCurlButton.tsx","./src/components/Dashboard.tsx","./src/components/DateRangePicker.tsx","./src/components/DepositPreview.tsx","./src/components/Dropdown.tsx","./src/components/EmbedPreview.tsx","./src/components/EmptyState.tsx","./src/components/EndpointGroupHover.tsx","./src/components/EndpointPreview.tsx","./src/components/ExternalLink.tsx","./src/components/FiltersBottomSheet.tsx","./src/components/FiltersSidebar.tsx","./src/components/FormField.tsx","./src/components/HealthTimeline.tsx","./src/components/JsonViewer.tsx","./src/components/KbdHint.tsx","./src/components/LiveRegion.tsx","./src/components/LowBalanceBanner.tsx","./src/components/MethodChip.tsx","./src/components/NotFound.tsx","./src/components/OpenAPIImport.tsx","./src/components/Pagination.tsx","./src/components/ParamsBuilder.tsx","./src/components/PlanBadge.tsx","./src/components/PlanNudge.tsx","./src/components/PreviewCard.tsx","./src/components/PricingTierTable.tsx","./src/components/RatingHistogram.tsx","./src/components/RecentlyActiveRail.tsx","./src/components/RelatedApisRail.tsx","./src/components/RequestBodyEditor.tsx","./src/components/RequestHistoryPanel.tsx","./src/components/RouteProgressBar.tsx","./src/components/RunExampleButton.tsx","./src/components/SearchBar.tsx","./src/components/ServerError.tsx","./src/components/ServerErrorDemo.tsx","./src/components/ShortcutsModal.tsx","./src/components/Skeleton.tsx","./src/components/SortDropdown.tsx","./src/components/Sparkline.tsx","./src/components/StarRating.tsx","./src/components/StatusBadge.tsx","./src/components/SubscribeButton.tsx","./src/components/Tabs.tsx","./src/components/TagChip.tsx","./src/components/TestInBrowser.tsx","./src/components/Toast.tsx","./src/components/TokenEditor.tsx","./src/components/Tooltip.tsx","./src/components/TopProgressBar.tsx","./src/components/UsageGauge.tsx","./src/components/WhyApi.tsx","./src/components/icons/BoltIcon.tsx","./src/components/icons/CheckIcon.tsx","./src/components/icons/ChevronIcon.tsx","./src/components/icons/ClockIcon.tsx","./src/components/icons/LinkIcon.tsx","./src/components/icons/TagIcon.tsx","./src/components/icons/WarningIcon.tsx","./src/components/icons/index.tsx","./src/config/constants.ts","./src/data/mockApis.ts","./src/hooks/useDebounce.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useFavorites.ts","./src/hooks/useFetchTracker.tsx","./src/hooks/useGlobalShortcuts.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePersistedState.ts","./src/hooks/useQuota.ts","./src/hooks/useRouteLoading.ts","./src/pages/A11yAudit.tsx","./src/pages/ApiDetailPage.skeleton.tsx","./src/pages/ApiDetailPage.tsx","./src/pages/ApiReviews.tsx","./src/pages/ApiTagFilter.tsx","./src/pages/ApiUsage.tsx","./src/pages/CallHistoryRow.tsx","./src/pages/DashboardOverview.tsx","./src/pages/DashboardPage.tsx","./src/pages/DesignSystemDocs.tsx","./src/pages/EndpointSummary.tsx","./src/pages/FiltersSidebar.tsx","./src/pages/InvoiceCard.tsx","./src/pages/MarketplacePage.skeleton.tsx","./src/pages/MarketplacePage.tsx","./src/pages/MyApis.tsx","./src/pages/PlanBadge.tsx","./src/pages/PricingTable.tsx","./src/pages/PublishApi.tsx","./src/pages/QuotaBanner.tsx","./src/pages/RateLimitCard.tsx","./src/pages/ReviewsTab.tsx","./src/pages/RiskGauge.tsx","./src/pages/SearchInput.tsx","./src/pages/StickyToc.tsx","./src/pages/SubscribeCTA.tsx","./src/pages/ThemePlayground.tsx","./src/pages/ThemeToggle.tsx","./src/state/collectionsStore.tsx","./src/state/compareStore.ts","./src/state/pinnedApis.ts","./src/state/testCallHistory.ts","./src/state/uiPrefs.ts","./src/state/userPrefs.ts","./src/utils/colorFromId.ts","./src/utils/density.ts","./src/utils/diff.ts","./src/utils/format.ts","./src/utils/icons.ts","./src/utils/openapi-parse.ts","./src/utils/postman.ts","./src/utils/schema-validate.ts","./src/utils/snapshotUrl.ts","./src/utils/toCurl.ts","./src/utils/userPrefs.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file