diff --git a/src/components/PlanBadge.test.tsx b/src/components/PlanBadge.test.tsx new file mode 100644 index 0000000..1a6a5d1 --- /dev/null +++ b/src/components/PlanBadge.test.tsx @@ -0,0 +1,29 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import PlanBadge from "./PlanBadge"; + +describe("PlanBadge", () => { + afterEach(() => cleanup()); + + it("renders the tier label", () => { + render(); + expect(screen.getByText("Pro")).toBeTruthy(); + }); + + it("exposes rate-limit details in the accessible name", () => { + render(); + expect( + screen.getByLabelText(/free plan\. rate limit: 60 requests \/ minute\./i), + ).toBeTruthy(); + }); + + it("reveals a tooltip with rate-limit details on focus", () => { + render(); + fireEvent.focus(screen.getByText("Enterprise")); + const tip = screen.getByRole("tooltip"); + expect(tip.textContent).toContain("Enterprise plan"); + expect(tip.textContent).toContain("Custom / unmetered"); + }); +}); diff --git a/src/components/PlanBadge.tsx b/src/components/PlanBadge.tsx new file mode 100644 index 0000000..dba5066 --- /dev/null +++ b/src/components/PlanBadge.tsx @@ -0,0 +1,92 @@ +import Tooltip from "./Tooltip"; + +/** + * PlanBadge — a small pill that labels an API/account plan tier and, on + * hover / focus / long-press, explains the tier with its rate-limit details + * via an accessible {@link Tooltip} (issue #283). + */ + +export type PlanTier = "free" | "pro" | "enterprise"; + +type PlanMeta = { + label: string; + /** Human-readable rate limit shown in the tooltip. */ + rateLimit: string; + description: string; + /** Token names that resolve to theme-aware colours. */ + bg: string; + fg: string; +}; + +const PLAN_META: Record = { + free: { + label: "Free", + rateLimit: "60 requests / minute", + description: "Best for prototyping and evaluation.", + bg: "var(--plan-free-bg, #e5e7eb)", + fg: "var(--plan-free-fg, #374151)", + }, + pro: { + label: "Pro", + rateLimit: "1,000 requests / minute", + description: "Higher throughput and priority support for production apps.", + bg: "var(--plan-pro-bg, #dbeafe)", + fg: "var(--plan-pro-fg, #1e40af)", + }, + enterprise: { + label: "Enterprise", + rateLimit: "Custom / unmetered", + description: "Dedicated capacity, SLAs, and custom rate limits.", + bg: "var(--plan-enterprise-bg, #ede9fe)", + fg: "var(--plan-enterprise-fg, #5b21b6)", + }, +}; + +type PlanBadgeProps = { + tier: PlanTier; + className?: string; +}; + +export default function PlanBadge({ + tier, + className, +}: PlanBadgeProps): JSX.Element { + const meta = PLAN_META[tier]; + + const tooltipContent = ( + + + {meta.label} plan + + {meta.description} + + Rate limit: {meta.rateLimit} + + + ); + + return ( + + + {meta.label} + + + ); +} diff --git a/src/components/Tooltip.test.tsx b/src/components/Tooltip.test.tsx new file mode 100644 index 0000000..e953042 --- /dev/null +++ b/src/components/Tooltip.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import Tooltip from "./Tooltip"; + +describe("Tooltip", () => { + afterEach(() => cleanup()); + + it("is hidden by default", () => { + render( + + + , + ); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("shows on hover and hides on leave", () => { + render( + + + , + ); + const trigger = screen.getByText("Trigger"); + fireEvent.mouseEnter(trigger); + expect(screen.getByRole("tooltip")).toBeTruthy(); + fireEvent.mouseLeave(trigger); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); + + it("shows on keyboard focus and links via aria-describedby", () => { + render( + + + , + ); + const trigger = screen.getByText("Trigger"); + fireEvent.focus(trigger); + const tip = screen.getByRole("tooltip"); + expect(trigger.getAttribute("aria-describedby")).toBe(tip.id); + }); + + it("dismisses on Escape", () => { + render( + + + , + ); + fireEvent.mouseEnter(screen.getByText("Trigger")); + expect(screen.getByRole("tooltip")).toBeTruthy(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("tooltip")).toBeNull(); + }); +}); diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 0000000..24445a7 --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,120 @@ +import { + cloneElement, + isValidElement, + useEffect, + useId, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; + +/** + * Tooltip — an accessible tooltip that opens on hover, keyboard focus, and + * touch long-press (issue #283). + * + * Accessibility (WCAG 2.1 AA): + * - The trigger gets `aria-describedby` pointing at the tooltip content. + * - Content has `role="tooltip"`. + * - Escape dismisses an open tooltip. + * - Colours come from design tokens so it reads in light and dark mode. + */ + +type TooltipProps = { + /** Tooltip body. Plain text or rich nodes. */ + content: ReactNode; + /** Single focusable trigger element (e.g. a or