Tooltip
v0.2.0Names a control on hover and on keyboard focus, built on [`@radix-ui/react-tooltip`](https://www.radix-ui.com/primitives/docs/components/tooltip).
registry/behivetech/atoms/tooltip · depends on @behivetech/get-class-name
tooltip.tsx
1import { isValidElement, type ReactElement } from "react";2import * as RadixTooltip from "@radix-ui/react-tooltip";3import { getClassName } from "@behivetech/get-class-name";4import styles from "./tooltip.module.scss";56/**7 * Shared open delay. Lives here rather than on each caller so tooltips feel the8 * same everywhere — the `title` attribute's roughly-one-second delay is one of9 * the reasons this component exists.10 */11const DELAY_DURATION = 300;1213export type TooltipSide = "top" | "right" | "bottom" | "left";1415export interface TooltipProps {16 /** Text shown in the tooltip. This is the control's name when the trigger is icon-only. */17 label: string;18 /** Which side of the trigger to place the tooltip on; defaults to "top" */19 side?: TooltipSide;20 /** The element the tooltip describes — rendered as-is via Radix's `asChild` */21 children: ReactElement;22 /** Additional class names to merge with the tooltip content element */23 className?: string;24}2526function isDisabled(element: ReactElement): boolean {27 if (!isValidElement(element)) return false;28 const { disabled } = element.props as { disabled?: boolean };29 return disabled === true;30}3132/**33 * Names a control on hover and on keyboard focus, built on Radix's `Tooltip`.34 *35 * Mounts its own `Tooltip.Provider`, so a caller never has to add one.36 *37 * Works on a disabled trigger, which a bare Radix tooltip does not: a disabled38 * element fires no pointer events, so the trigger never sees hover or focus.39 * When the child is disabled it is wrapped in a focusable span that carries the40 * listeners instead, and the child is made pointer-transparent so hover lands41 * on the wrapper. That keeps the explanation reachable at the one moment it42 * matters most — when the control is unavailable and the user wants to know43 * why — including by keyboard, since a disabled control is not focusable.44 */45export const Tooltip = ({46 label,47 side = "top",48 children,49 className,50}: TooltipProps) => {51 const [, getChildClass] = getClassName({52 className,53 rootClass: "BHT__Tooltip",54 styles,55 });5657 const disabled = isDisabled(children);5859 // The wrapper is deliberately left unlabelled: the disabled control inside60 // keeps its own accessible name, and Radix points the wrapper's61 // `aria-describedby` at the tooltip, so focusing it announces both.62 const trigger = disabled ? (63 <span className={getChildClass("disabledTrigger")} tabIndex={0}>64 {children}65 </span>66 ) : (67 children68 );6970 return (71 <RadixTooltip.Provider delayDuration={DELAY_DURATION}>72 <RadixTooltip.Root>73 <RadixTooltip.Trigger asChild>{trigger}</RadixTooltip.Trigger>74 <RadixTooltip.Portal>75 <RadixTooltip.Content76 className={getChildClass("content")}77 side={side}78 sideOffset={6}79 >80 {label}81 <RadixTooltip.Arrow className={getChildClass("arrow")} />82 </RadixTooltip.Content>83 </RadixTooltip.Portal>84 </RadixTooltip.Root>85 </RadixTooltip.Provider>86 );87};
tooltip.module.scss
1// Material Design 3 plain tooltip. `inverse-surface` has no pre-MD3 name, so2// its fallback is the old on-surface/surface pair swapped — which is exactly3// what "inverse" means.45.BHT__Tooltip {6 &__content {7 padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);8 background: var(--md-sys-color-inverse-surface, var(--color-on-surface));9 color: var(--md-sys-color-inverse-on-surface, var(--color-surface));10 border-radius: var(--md-sys-shape-corner-extra-small, 4px);11 font-family: var(--md-sys-typescale-body-small-font, var(--font-body));12 font-size: var(--md-sys-typescale-body-small-size, 0.75rem);13 font-weight: var(--md-sys-typescale-body-small-weight, 400);14 line-height: var(--md-sys-typescale-body-small-line-height, 1rem);15 letter-spacing: var(--md-sys-typescale-body-small-tracking, 0.0333em);16 max-width: 16rem;17 z-index: var(--z-tooltip, 600);18 user-select: none;1920 &[data-state="delayed-open"] {21 animation: tooltip-in var(--md-sys-motion-duration-short-3, 150ms)22 var(--md-sys-motion-easing-standard-decelerate, ease-out);23 }24 }2526 &__arrow {27 fill: var(--md-sys-color-inverse-surface, var(--color-on-surface));28 }2930 // A disabled control fires no pointer events of its own, so the wrapper takes31 // the listeners and the control is made transparent to the pointer.32 &__disabledTrigger {33 display: inline-flex;3435 > * {36 pointer-events: none;37 }3839 &:focus-visible {40 outline: 3px solid41 var(--md-sys-color-secondary, var(--color-border-focus));42 outline-offset: 2px;43 }44 }45}4647@keyframes tooltip-in {48 from {49 opacity: 0;50 }5152 to {53 opacity: 1;54 }55}
index.ts
1export { Tooltip } from "./tooltip.js";2export type { TooltipProps, TooltipSide } from "./tooltip.js";
tooltip.composition.tsx
1import { Tooltip } from "./tooltip.js";23export const BasicTooltip = () => (4 <Tooltip label="Rectangle">5 <button type="button">Rect</button>6 </Tooltip>7);89export const TooltipSides = () => (10 <div style={{ display: "flex", gap: "1.5rem", padding: "3rem" }}>11 <Tooltip label="Above" side="top">12 <button type="button">Top</button>13 </Tooltip>14 <Tooltip label="To the right" side="right">15 <button type="button">Right</button>16 </Tooltip>17 <Tooltip label="Below" side="bottom">18 <button type="button">Bottom</button>19 </Tooltip>20 <Tooltip label="To the left" side="left">21 <button type="button">Left</button>22 </Tooltip>23 </div>24);2526/** The case a bare Radix tooltip cannot do — and the reason this is an atom. */27export const DisabledTrigger = () => (28 <Tooltip label="You do not have permission to edit roads" side="right">29 <button type="button" disabled>30 Road31 </button>32 </Tooltip>33);
tooltip.spec.tsx
1import { render, screen, waitFor } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Tooltip } from "./tooltip.js";45describe("Tooltip", () => {6 it("renders the trigger and no tooltip until asked", () => {7 render(8 <Tooltip label="Rectangle">9 <button type="button">Rect</button>10 </Tooltip>,11 );12 expect(screen.getByRole("button", { name: "Rect" })).toBeTruthy();13 expect(screen.queryByRole("tooltip")).toBeNull();14 });1516 it("opens on hover", async () => {17 const user = userEvent.setup();18 render(19 <Tooltip label="Rectangle">20 <button type="button">Rect</button>21 </Tooltip>,22 );2324 await user.hover(screen.getByRole("button", { name: "Rect" }));25 expect(await screen.findByRole("tooltip")).toHaveTextContent("Rectangle");26 });2728 it("opens on keyboard focus", async () => {29 const user = userEvent.setup();30 render(31 <Tooltip label="Rectangle">32 <button type="button">Rect</button>33 </Tooltip>,34 );3536 await user.tab();37 expect(screen.getByRole("button", { name: "Rect" })).toHaveFocus();38 expect(await screen.findByRole("tooltip")).toHaveTextContent("Rectangle");39 });4041 it("dismisses on Escape", async () => {42 const user = userEvent.setup();43 render(44 <Tooltip label="Rectangle">45 <button type="button">Rect</button>46 </Tooltip>,47 );4849 await user.tab();50 await screen.findByRole("tooltip");5152 await user.keyboard("{Escape}");53 await waitFor(() => {54 expect(screen.queryByRole("tooltip")).toBeNull();55 });56 });5758 it("places the tooltip on the requested side", async () => {59 const user = userEvent.setup();60 render(61 <Tooltip label="Rectangle" side="right">62 <button type="button">Rect</button>63 </Tooltip>,64 );6566 await user.tab();67 const tooltip = await screen.findByRole("tooltip");68 expect(tooltip.closest("[data-side]")).toHaveAttribute(69 "data-side",70 "right",71 );72 });7374 describe("disabled trigger", () => {75 it("keeps the trigger disabled and still focusable by keyboard", async () => {76 const user = userEvent.setup();77 render(78 <Tooltip label="No permission">79 <button type="button" disabled>80 Road81 </button>82 </Tooltip>,83 );8485 const button = screen.getByRole("button", { name: "Road", hidden: true });86 expect(button).toBeDisabled();8788 await user.tab();89 expect(await screen.findByRole("tooltip")).toHaveTextContent(90 "No permission",91 );92 });9394 it("opens on hover over the disabled control", async () => {95 const user = userEvent.setup();96 render(97 <Tooltip label="No permission">98 <button type="button" disabled>99 Road100 </button>101 </Tooltip>,102 );103104 const wrapper = screen.getByRole("button", {105 name: "Road",106 hidden: true,107 }).parentElement as HTMLElement;108109 await user.hover(wrapper);110 expect(await screen.findByRole("tooltip")).toHaveTextContent(111 "No permission",112 );113 });114115 it("describes the wrapper so the reason is announced", async () => {116 const user = userEvent.setup();117 render(118 <Tooltip label="No permission">119 <button type="button" disabled>120 Road121 </button>122 </Tooltip>,123 );124125 await user.tab();126 await screen.findByRole("tooltip");127128 const wrapper = screen.getByRole("button", {129 name: "Road",130 hidden: true,131 }).parentElement as HTMLElement;132 expect(wrapper).toHaveAttribute("aria-describedby");133 });134135 it("does not wrap an enabled trigger", () => {136 render(137 <Tooltip label="Rectangle">138 <button type="button">Rect</button>139 </Tooltip>,140 );141142 const button = screen.getByRole("button", { name: "Rect" });143 expect(button).toHaveAttribute("data-state");144 expect(button.parentElement?.tagName).not.toBe("SPAN");145 });146 });147});