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
29 changes: 29 additions & 0 deletions src/components/PlanBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PlanBadge tier="pro" />);
expect(screen.getByText("Pro")).toBeTruthy();
});

it("exposes rate-limit details in the accessible name", () => {
render(<PlanBadge tier="free" />);
expect(
screen.getByLabelText(/free plan\. rate limit: 60 requests \/ minute\./i),
).toBeTruthy();
});

it("reveals a tooltip with rate-limit details on focus", () => {
render(<PlanBadge tier="enterprise" />);
fireEvent.focus(screen.getByText("Enterprise"));
const tip = screen.getByRole("tooltip");
expect(tip.textContent).toContain("Enterprise plan");
expect(tip.textContent).toContain("Custom / unmetered");
});
});
92 changes: 92 additions & 0 deletions src/components/PlanBadge.tsx
Original file line number Diff line number Diff line change
@@ -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<PlanTier, PlanMeta> = {
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 = (
<span>
<strong style={{ display: "block", marginBottom: "0.25rem" }}>
{meta.label} plan
</strong>
<span style={{ display: "block" }}>{meta.description}</span>
<span style={{ display: "block", marginTop: "0.25rem", opacity: 0.85 }}>
Rate limit: {meta.rateLimit}
</span>
</span>
);

return (
<Tooltip content={tooltipContent}>
<span
className={className}
tabIndex={0}
aria-label={`${meta.label} plan. Rate limit: ${meta.rateLimit}.`}
style={{
display: "inline-flex",
alignItems: "center",
padding: "0.125rem 0.5rem",
borderRadius: "999px",
fontSize: "0.6875rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.03em",
cursor: "default",
background: meta.bg,
color: meta.fg,
}}
>
{meta.label}
</span>
</Tooltip>
);
}
55 changes: 55 additions & 0 deletions src/components/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Tooltip content="Helpful text">
<button>Trigger</button>
</Tooltip>,
);
expect(screen.queryByRole("tooltip")).toBeNull();
});

it("shows on hover and hides on leave", () => {
render(
<Tooltip content="Helpful text">
<button>Trigger</button>
</Tooltip>,
);
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(
<Tooltip content="Helpful text">
<button>Trigger</button>
</Tooltip>,
);
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(
<Tooltip content="Helpful text">
<button>Trigger</button>
</Tooltip>,
);
fireEvent.mouseEnter(screen.getByText("Trigger"));
expect(screen.getByRole("tooltip")).toBeTruthy();
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("tooltip")).toBeNull();
});
});
120 changes: 120 additions & 0 deletions src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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 <span> or <button>). */
children: ReactElement;
/** Long-press duration in ms before the tooltip opens on touch. */
longPressMs?: number;
};

export default function Tooltip({
content,
children,
longPressMs = 500,
}: TooltipProps): JSX.Element {
const [open, setOpen] = useState(false);
const tooltipId = useId();
const pressTimer = useRef<number | null>(null);

const clearPressTimer = () => {
if (pressTimer.current !== null) {
window.clearTimeout(pressTimer.current);
pressTimer.current = null;
}
};

useEffect(() => clearPressTimer, []);

useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);

const show = () => setOpen(true);
const hide = () => {
clearPressTimer();
setOpen(false);
};

const handleTouchStart = () => {
clearPressTimer();
pressTimer.current = window.setTimeout(() => setOpen(true), longPressMs);
};

const trigger = isValidElement(children) ? (
cloneElement(children, {
"aria-describedby": open ? tooltipId : undefined,
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
onTouchStart: handleTouchStart,
onTouchEnd: clearPressTimer,
onTouchCancel: clearPressTimer,
} as Record<string, unknown>)
) : (
children
);

return (
<span
style={{ position: "relative", display: "inline-flex" }}
onMouseLeave={hide}
>
{trigger}
{open && (
<span
role="tooltip"
id={tooltipId}
style={{
position: "absolute",
bottom: "calc(100% + 6px)",
left: "50%",
transform: "translateX(-50%)",
zIndex: 50,
maxWidth: "240px",
width: "max-content",
padding: "0.5rem 0.625rem",
borderRadius: "0.375rem",
fontSize: "0.75rem",
lineHeight: 1.4,
textAlign: "left",
color: "var(--tooltip-text, #ffffff)",
background: "var(--tooltip-bg, #1f2937)",
border: "1px solid var(--border-subtle, rgba(255,255,255,0.1))",
boxShadow: "0 4px 12px rgba(0,0,0,0.18)",
pointerEvents: "none",
}}
>
{content}
</span>
)}
</span>
);
}