diff --git a/README.md b/README.md index 652fdfe..6e87a63 100644 --- a/README.md +++ b/README.md @@ -289,37 +289,37 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", #### Controls -| Component | What it is | -| ------------------- | --------------------------------------------------------------- | -| `Button` | Button with variants and sizes | -| `Input` | Text input | -| `Textarea` | Multi-line input | -| `Select` | Native select | -| `Checkbox` | Checkbox | -| `SelectionCheckbox` | Row-selection checkbox | -| `Switch` | On/off toggle | -| `FileInput` | File picker | -| `Tabs` | Tab strip | -| `ViewToggle` | Grid or rows segmented control | -| `FilterBar` | Row of filters with an active-filter summary | -| `FilterChip` | One toggleable filter | -| `KindCardGrid` | Selectable card grid | -| `ActionBar` | Grouped view controls, inline or floating | -| `BulkActionBar` | Actions for a multi-row selection | -| `Table` | Table primitives | -| `SortableTable` | Table with sortable columns | -| `Badge` | Status label | -| `StatusDot` | Colored status dot | -| `Avatar` | Initials or image avatar | -| `ProviderMark` | Which service an action goes through | -| `Skeleton` | Loading placeholder | -| `Dialog` | Modal dialog (optional peer, `ui/dialog`) | -| `CommandPalette` | Global search overlay (optional peer, `ui/command-palette`) | -| `Menu` | Dropdown menu (optional peer, `ui/menu`) | -| `InfoTooltip` | Hover info tooltip (optional peer, `ui/tooltip`) | -| `Toaster` | Toast host; call `toast()` anywhere (optional peer, `ui/toast`) | -| `ThemeProvider` | Persists light, dark or system mode and presets | -| `ThemeToggle` | Light, dark or system switch | +| Component | What it is | +| ------------------------ | -------------------------------------------------------------------------------------- | +| `Button` | Button with variants and sizes | +| `Input` | Text input | +| `Textarea` | Multi-line input | +| `Select` | Native select | +| `Checkbox` | Checkbox | +| `SelectionCheckbox` | Row-selection checkbox | +| `Switch` | On/off toggle | +| `FileInput` | File picker | +| `Tabs` | Tab strip | +| `ViewToggle` | Grid or rows segmented control | +| `FilterBar` | Row of filters with an active-filter summary | +| `FilterChip` | One toggleable filter | +| `KindCardGrid` | Selectable card grid | +| `ActionBar` | Grouped view controls, inline or floating | +| `BulkActionBar` | Actions for a multi-row selection | +| `Table` | Table primitives | +| `SortableTable` | Table with sortable columns | +| `Badge` | Status label | +| `StatusDot` | Colored status dot | +| `Avatar` | Initials or image avatar | +| `ProviderMark` | Which service an action goes through | +| `Skeleton` | Loading placeholder | +| `Dialog` | Modal dialog (optional peer, `ui/dialog`) | +| `CommandPalette` | Global search overlay (optional peer, `ui/command-palette`) | +| `Menu` | Dropdown menu (optional peer, `ui/menu`) | +| `Tooltip`, `InfoTooltip` | General tooltip around any trigger, and hover info glyph (optional peer, `ui/tooltip`) | +| `Toaster` | Toast host; call `toast()` anywhere (optional peer, `ui/toast`) | +| `ThemeProvider` | Persists light, dark or system mode and presets | +| `ThemeToggle` | Light, dark or system switch | ## For coding agents diff --git a/src/ui/tooltip.test.tsx b/src/ui/tooltip.test.tsx index 9e87a1c..9782a59 100644 --- a/src/ui/tooltip.test.tsx +++ b/src/ui/tooltip.test.tsx @@ -2,9 +2,34 @@ import { describe, expect, test } from "bun:test"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; -import { InfoTooltip } from "./tooltip.js"; +import { InfoTooltip, Tooltip, type TooltipProps } from "./tooltip.js"; -function mount(label: string) { +function requireButton( + root: ParentNode, + selector = "button", +): HTMLButtonElement { + const element = root.querySelector(selector); + if (!(element instanceof HTMLButtonElement)) { + throw new Error(`Expected HTMLButtonElement for selector "${selector}"`); + } + return element; +} + +function requireDescribedByContent(button: HTMLButtonElement): HTMLElement { + const id = button.getAttribute("aria-describedby"); + expect(id).not.toBeNull(); + if (id === null) { + throw new Error("Expected aria-describedby attribute"); + } + const content = document.getElementById(id); + expect(content).not.toBeNull(); + if (content === null) { + throw new Error(`Expected element with id "${id}"`); + } + return content; +} + +function mountInfo(label: string) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -12,25 +37,52 @@ function mount(label: string) { root.render(createElement(InfoTooltip, { label })); }); return { - trigger: () => document.body.querySelector("button") as HTMLButtonElement, - unmount: () => root.unmount(), + trigger: () => requireButton(container), + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, + }; +} + +function mountTooltip(props: TooltipProps) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(createElement(Tooltip, props)); + }); + return { + container, + rerender: (next: TooltipProps) => { + act(() => { + root.render(createElement(Tooltip, next)); + }); + }, + unmount: () => { + act(() => { + root.unmount(); + }); + container.remove(); + }, }; } describe("InfoTooltip", () => { test("the trigger is a focusable button", () => { - const { trigger, unmount } = mount( + const { trigger, unmount } = mountInfo( "Positioned by event order, not real timing.", ); const button = trigger(); - expect(button).not.toBeNull(); expect(button.tagName).toBe("BUTTON"); expect(button.getAttribute("type")).toBe("button"); unmount(); }); test("focusing the trigger associates and reveals the tooltip content", async () => { - const { trigger, unmount } = mount( + const { trigger, unmount } = mountInfo( "Positioned by event order, not real timing.", ); const button = trigger(); @@ -43,18 +95,15 @@ describe("InfoTooltip", () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - const describedBy = button.getAttribute("aria-describedby"); - expect(describedBy).not.toBeNull(); - const content = document.getElementById(describedBy as string); - expect(content).not.toBeNull(); - expect(content?.textContent).toContain( + const content = requireDescribedByContent(button); + expect(content.textContent).toContain( "Positioned by event order, not real timing.", ); unmount(); }); test("Escape dismisses the tooltip", async () => { - const { trigger, unmount } = mount( + const { trigger, unmount } = mountInfo( "Positioned by event order, not real timing.", ); const button = trigger(); @@ -65,10 +114,8 @@ describe("InfoTooltip", () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); - const describedByWhileOpen = button.getAttribute("aria-describedby"); - expect(describedByWhileOpen).not.toBeNull(); - const contentIdWhileOpen = describedByWhileOpen as string; - expect(document.getElementById(contentIdWhileOpen)).not.toBeNull(); + const contentWhileOpen = requireDescribedByContent(button); + const contentIdWhileOpen = contentWhileOpen.id; act(() => { button.dispatchEvent( @@ -88,3 +135,156 @@ describe("InfoTooltip", () => { unmount(); }); }); + +describe("Tooltip", () => { + test("wraps a focusable element as the trigger and preserves element attributes", () => { + const { container, unmount } = mountTooltip({ + label: "Custom note", + children: ( + + ), + }); + const button = requireButton(container, "#my-action"); + expect(button.tagName).toBe("BUTTON"); + expect(button.classList.contains("custom-trigger")).toBe(true); + unmount(); + }); + + test("focusing the trigger reveals the tooltip and associates via aria-describedby", async () => { + const { container, unmount } = mountTooltip({ + label: "Helpful contextual detail", + children: , + }); + const button = requireButton(container); + + act(() => { + button.focus(); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const content = requireDescribedByContent(button); + expect(content.textContent).toContain("Helpful contextual detail"); + unmount(); + }); + + test("renders rich ReactNode content in the tooltip bubble", async () => { + const { container, unmount } = mountTooltip({ + label: ( + + Formatted bold note + + ), + children: , + }); + const button = requireButton(container); + + act(() => { + button.focus(); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const content = requireDescribedByContent(button); + expect(content.querySelector("[data-testid='rich-label']")).not.toBeNull(); + expect(content.querySelector("strong")?.textContent).toBe("bold"); + unmount(); + }); + + test("Escape dismisses the open tooltip", async () => { + const { container, unmount } = mountTooltip({ + label: "Dismiss me with escape", + children: , + }); + const button = requireButton(container); + + act(() => { + button.focus(); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const contentWhileOpen = requireDescribedByContent(button); + const describedBy = contentWhileOpen.id; + + act(() => { + button.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(button.getAttribute("aria-describedby")).toBeNull(); + expect(document.getElementById(describedBy)).toBeNull(); + unmount(); + }); + + test("accepts custom side and className on the tooltip content", async () => { + const { container, unmount } = mountTooltip({ + label: "Side note", + side: "right", + className: "custom-tooltip-class", + children: , + }); + const button = requireButton(container); + + act(() => { + button.focus(); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const content = requireDescribedByContent(button); + expect(content.getAttribute("data-side")).toBe("right"); + expect(content.classList.contains("custom-tooltip-class")).toBe(true); + unmount(); + }); + + test("supports controlled open state and onOpenChange", async () => { + const events: boolean[] = []; + const { container, rerender, unmount } = mountTooltip({ + label: "Controlled note", + open: true, + onOpenChange: (next) => { + events.push(next); + }, + children: , + }); + const button = requireButton(container); + const content = requireDescribedByContent(button); + expect(content.textContent).toContain("Controlled note"); + + act(() => { + button.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ); + }); + expect(events).toEqual([false]); + + rerender({ + label: "Controlled note", + open: false, + onOpenChange: (next) => { + events.push(next); + }, + children: , + }); + expect(button.getAttribute("aria-describedby")).toBeNull(); + unmount(); + }); +}); diff --git a/src/ui/tooltip.tsx b/src/ui/tooltip.tsx index ca95cb5..0a8fd6a 100644 --- a/src/ui/tooltip.tsx +++ b/src/ui/tooltip.tsx @@ -2,17 +2,94 @@ import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { InfoIcon } from "lucide-react"; +import * as React from "react"; import { cn } from "../lib/utils.js"; +export type TooltipSide = "top" | "right" | "bottom" | "left"; + +export type TooltipProps = { + /** The content shown in the tooltip bubble and bound to the trigger via `aria-describedby`. */ + readonly label: React.ReactNode; + /** Focusable trigger element that discloses the tooltip on hover or keyboard focus. */ + readonly children: React.ReactElement; + /** Preferred side of the trigger to render against. Defaults to "top". */ + readonly side?: TooltipSide | undefined; + /** Offset distance in pixels from the trigger. Defaults to 6. */ + readonly sideOffset?: number | undefined; + /** Delay in milliseconds before opening on hover. Defaults to 200. */ + readonly delay?: number | undefined; + /** Render already open — uncontrolled, for stories and screenshots. */ + readonly defaultOpen?: boolean | undefined; + /** Controlled open state. */ + readonly open?: boolean | undefined; + /** Callback fired when the open state changes. */ + readonly onOpenChange?: ((open: boolean) => void) | undefined; + /** Additional class names applied to the tooltip content bubble. */ + readonly className?: string | undefined; +}; + +/** + * A general tooltip that wraps a focusable trigger element and discloses a note on hover or focus. + * + * Pass a keyboard-focusable element (such as ` - - - - {label} - - - - - + + + ); } diff --git a/stories/primitives/tooltip.stories.tsx b/stories/primitives/tooltip.stories.tsx index c562228..8202acf 100644 --- a/stories/primitives/tooltip.stories.tsx +++ b/stories/primitives/tooltip.stories.tsx @@ -1,4 +1,5 @@ -import { InfoTooltip } from "../../src/ui/tooltip.js"; +import { Button } from "../../src/ui/button.js"; +import { InfoTooltip, Tooltip } from "../../src/ui/tooltip.js"; export default { title: "Primitives / Tooltip" }; @@ -13,7 +14,7 @@ export const Basic = () => ( // contrast against `--popover` are visible without hovering — check this in // both themes. export const Open = () => ( -
+
Token budget ( />
); + +export const General = () => ( +
+ + + + + + + + + + + +
+); + +export const PlacementSides = () => ( +
+ + + + + + + + + + + + +
+); + +export const RichLabel = () => ( +
+ + API Quota + + 8,420 of 10,000 monthly credits used. + +
+ } + > + + +
+);