Behive Tech Registry

Tooltip

v0.2.0

Names a control on hover and on keyboard focus, built on [`@radix-ui/react-tooltip`](https://www.radix-ui.com/primitives/docs/components/tooltip).

pnpm add @behivetech/atoms.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";
5
6/**
7 * Shared open delay. Lives here rather than on each caller so tooltips feel the
8 * same everywhere — the `title` attribute's roughly-one-second delay is one of
9 * the reasons this component exists.
10 */
11const DELAY_DURATION = 300;
12
13export type TooltipSide = "top" | "right" | "bottom" | "left";
14
15export 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}
25
26function isDisabled(element: ReactElement): boolean {
27 if (!isValidElement(element)) return false;
28 const { disabled } = element.props as { disabled?: boolean };
29 return disabled === true;
30}
31
32/**
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 disabled
38 * 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 the
40 * listeners instead, and the child is made pointer-transparent so hover lands
41 * on the wrapper. That keeps the explanation reachable at the one moment it
42 * matters most — when the control is unavailable and the user wants to know
43 * 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 });
56
57 const disabled = isDisabled(children);
58
59 // The wrapper is deliberately left unlabelled: the disabled control inside
60 // keeps its own accessible name, and Radix points the wrapper's
61 // `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 children
68 );
69
70 return (
71 <RadixTooltip.Provider delayDuration={DELAY_DURATION}>
72 <RadixTooltip.Root>
73 <RadixTooltip.Trigger asChild>{trigger}</RadixTooltip.Trigger>
74 <RadixTooltip.Portal>
75 <RadixTooltip.Content
76 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, so
2// its fallback is the old on-surface/surface pair swapped — which is exactly
3// what "inverse" means.
4
5.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;
19
20 &[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 }
25
26 &__arrow {
27 fill: var(--md-sys-color-inverse-surface, var(--color-on-surface));
28 }
29
30 // A disabled control fires no pointer events of its own, so the wrapper takes
31 // the listeners and the control is made transparent to the pointer.
32 &__disabledTrigger {
33 display: inline-flex;
34
35 > * {
36 pointer-events: none;
37 }
38
39 &:focus-visible {
40 outline: 3px solid
41 var(--md-sys-color-secondary, var(--color-border-focus));
42 outline-offset: 2px;
43 }
44 }
45}
46
47@keyframes tooltip-in {
48 from {
49 opacity: 0;
50 }
51
52 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";
2
3export const BasicTooltip = () => (
4 <Tooltip label="Rectangle">
5 <button type="button">Rect</button>
6 </Tooltip>
7);
8
9export 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);
25
26/** 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 Road
31 </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";
4
5describe("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 });
15
16 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 );
23
24 await user.hover(screen.getByRole("button", { name: "Rect" }));
25 expect(await screen.findByRole("tooltip")).toHaveTextContent("Rectangle");
26 });
27
28 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 );
35
36 await user.tab();
37 expect(screen.getByRole("button", { name: "Rect" })).toHaveFocus();
38 expect(await screen.findByRole("tooltip")).toHaveTextContent("Rectangle");
39 });
40
41 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 );
48
49 await user.tab();
50 await screen.findByRole("tooltip");
51
52 await user.keyboard("{Escape}");
53 await waitFor(() => {
54 expect(screen.queryByRole("tooltip")).toBeNull();
55 });
56 });
57
58 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 );
65
66 await user.tab();
67 const tooltip = await screen.findByRole("tooltip");
68 expect(tooltip.closest("[data-side]")).toHaveAttribute(
69 "data-side",
70 "right",
71 );
72 });
73
74 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 Road
81 </button>
82 </Tooltip>,
83 );
84
85 const button = screen.getByRole("button", { name: "Road", hidden: true });
86 expect(button).toBeDisabled();
87
88 await user.tab();
89 expect(await screen.findByRole("tooltip")).toHaveTextContent(
90 "No permission",
91 );
92 });
93
94 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 Road
100 </button>
101 </Tooltip>,
102 );
103
104 const wrapper = screen.getByRole("button", {
105 name: "Road",
106 hidden: true,
107 }).parentElement as HTMLElement;
108
109 await user.hover(wrapper);
110 expect(await screen.findByRole("tooltip")).toHaveTextContent(
111 "No permission",
112 );
113 });
114
115 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 Road
121 </button>
122 </Tooltip>,
123 );
124
125 await user.tab();
126 await screen.findByRole("tooltip");
127
128 const wrapper = screen.getByRole("button", {
129 name: "Road",
130 hidden: true,
131 }).parentElement as HTMLElement;
132 expect(wrapper).toHaveAttribute("aria-describedby");
133 });
134
135 it("does not wrap an enabled trigger", () => {
136 render(
137 <Tooltip label="Rectangle">
138 <button type="button">Rect</button>
139 </Tooltip>,
140 );
141
142 const button = screen.getByRole("button", { name: "Rect" });
143 expect(button).toHaveAttribute("data-state");
144 expect(button.parentElement?.tagName).not.toBe("SPAN");
145 });
146 });
147});