@uniflowed/ui 0.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tooltip.js ADDED
@@ -0,0 +1,411 @@
1
+ // @flow
2
+ //
3
+ // A tooltip: the component most likely to make a page *worse* than the plain
4
+ // HTML it replaced.
5
+ //
6
+ // The failure is specified rather than a matter of taste. WCAG 2.1 SC 1.4.13,
7
+ // *Content on Hover or Focus*, requires content shown that way to be
8
+ // dismissible with `Escape`, hoverable — the pointer can travel onto it — and
9
+ // persistent until one of those things ends it. A `title` attribute fails all
10
+ // three: it is on the browser's clock, it cannot be hovered, and `Escape` does
11
+ // nothing. A `div` that appears on `:hover` fails the first two. Those are the
12
+ // two things a project without this component writes.
13
+ //
14
+ // `internal/hover-intent.js` holds all three clauses, because they are one
15
+ // mechanism and separating them is how a component ends up with two of them.
16
+ //
17
+ // # What a tooltip is not
18
+ //
19
+ // It is **not focusable**, and nothing in it may be. A tooltip that takes focus
20
+ // puts a stop in the tab order the reader did not ask for and cannot leave in
21
+ // the direction they expect; the APG says so, and it is why anything with a
22
+ // link or a button in it is a `HoverCard` and not this. It carries
23
+ // `role="tooltip"` and describes its trigger with `aria-describedby` — and only
24
+ // while it is in the document, because a reference to an element that is not
25
+ // there makes a screen reader announce nothing at all.
26
+ //
27
+ // It has **no `aria-haspopup`**. That attribute promises an interactive popup,
28
+ // and a reader who is told a button has one and then finds nothing to interact
29
+ // with has been sent somewhere that does not exist.
30
+ //
31
+ // # The delay, and the one that must not be there
32
+ //
33
+ // A pointer resting on a trigger opens the tooltip after a delay; a pointer
34
+ // crossing a toolbar on its way elsewhere passes six triggers and must open
35
+ // none of them. **Focus opens it with no delay at all** — a reader who tabbed
36
+ // to a control has already said what they want, and a wait exists to filter out
37
+ // intent nobody expressed.
38
+ //
39
+ // `Tooltip.Provider` shares one clock between a group of tooltips, so the
40
+ // second icon in a toolbar answers immediately once the reader has waited out
41
+ // the first. A tooltip outside a provider is a complete tooltip and keeps its
42
+ // own delay.
43
+ //
44
+ // # Touch, which is a decision rather than an accident
45
+ //
46
+ // **This does not open on touch.** There is no hover on a touch screen, so the
47
+ // only gesture left is the tap — and a tooltip that opens on tap either
48
+ // swallows the tap the control needed or shows content the next tap dismisses
49
+ // before it has been read. Both are worse than nothing.
50
+ //
51
+ // What follows from that is a rule for the caller rather than for this module:
52
+ // a tooltip may not be the only place something is said. If the trigger is an
53
+ // icon button, the tooltip's text is what a reader on a phone needs *as the
54
+ // button's name* — so give the button an `aria-label` and let the tooltip
55
+ // repeat it. `pointerenter` is ignored for a touch pointer here, which is what
56
+ // stops the tap being eaten.
57
+ //
58
+ // # Why the pointer is watched natively
59
+ //
60
+ // `pointerenter` and `pointerleave` are the platform's way of saying the
61
+ // pointer arrived at *this* element; React's `onPointerEnter` is its own
62
+ // reconstruction of them from `pointerover` and `pointerout`. The reconstruction
63
+ // is faithful and it is not the same event — and the property this component
64
+ // has to read to know a tap from a hover, `pointerType`, belongs to the pointer
65
+ // event the platform sent. So the listeners go on the element, through
66
+ // `@uniflowed/hooks`' `useEventListener`, which is also what stops a caller's
67
+ // `render` function from dropping a handler by forgetting to spread it.
68
+
69
+ "use client";
70
+
71
+ import * as React from "@uniflowed/react";
72
+ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
73
+ import { useEventListener } from "@uniflowed/hooks/dom";
74
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
75
+
76
+ import type { Align, Side } from "./internal/anchor.js";
77
+ import type { DelayGroup, HoverIntent } from "./internal/hover-intent.js";
78
+ import type { Rest } from "./internal/merge-props.js";
79
+ import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
80
+ import {
81
+ DEFAULT_CLOSE_DELAY,
82
+ DEFAULT_OPEN_DELAY,
83
+ DEFAULT_SKIP_DELAY,
84
+ useDelayGroup,
85
+ useDismissOnEscape,
86
+ useFocusableTrigger,
87
+ useHoverIntent,
88
+ } from "./internal/hover-intent.js";
89
+ import { useAnchor } from "./internal/anchor.js";
90
+ import { useControlled } from "./internal/controlled-state.js";
91
+
92
+ export type { Align, Side } from "./internal/anchor.js";
93
+
94
+ /** What a `Tooltip.Provider` shares with the tooltips inside it. */
95
+ type TooltipScope = {|
96
+ /** The default `openDelay` for every tooltip in the group. */
97
+ readonly delayDuration: number,
98
+ readonly group: DelayGroup,
99
+ |};
100
+
101
+ const TooltipScopeContext: React.Context<TooltipScope | null> = createContext(null);
102
+
103
+ type TooltipState = {|
104
+ readonly base: string,
105
+ readonly open: boolean,
106
+ readonly setOpen: (open: boolean) => void,
107
+ readonly triggerRef: { current: HTMLElement | null },
108
+ readonly intent: HoverIntent,
109
+ /**
110
+ * How long a pointer must rest before this tooltip opens, asked at the moment
111
+ * it arrives rather than read from a render.
112
+ *
113
+ * A function because the answer is the group's and changes as the reader
114
+ * moves: the same tooltip waits the full delay when it is the first one
115
+ * touched and opens at once when it is the second.
116
+ */
117
+ readonly openDelay: () => number,
118
+ readonly closeDelay: number,
119
+ /**
120
+ * Whether `Escape` has dismissed it while the reader is still here.
121
+ *
122
+ * A ref rather than state because nothing renders it, and because what it
123
+ * guards happens inside an event handler: without it, a dismissal is undone
124
+ * by the very next thing the pointer or focus does — which for a hover card
125
+ * is the focus it hands back to its own trigger, and is a component that
126
+ * cannot be closed. It is cleared when the reader leaves the trigger, so
127
+ * coming back opens it again.
128
+ */
129
+ readonly dismissed: { current: boolean },
130
+ |};
131
+
132
+ const TooltipContext: React.Context<TooltipState | null> = createContext(null);
133
+
134
+ hook useTooltip(part: string): TooltipState {
135
+ const state = useContext(TooltipContext);
136
+ if (state == null) {
137
+ throw new Error(`${part} must be rendered inside a Tooltip.Root`);
138
+ }
139
+ return state;
140
+ }
141
+
142
+ /**
143
+ * One clock for a group of tooltips.
144
+ *
145
+ * `delayDuration` is the delay each tooltip inside uses unless it sets its own,
146
+ * so a toolbar says it once. `skipDelayDuration` is the window after one closes
147
+ * during which the next opens immediately — the interaction that makes a row of
148
+ * icon buttons readable rather than tedious.
149
+ *
150
+ * Renders no element: it is a context and a clock, and a `<div>` around a
151
+ * toolbar is the caller's business.
152
+ */
153
+ export component TooltipProvider(
154
+ children: React.Node,
155
+ delayDuration?: number = DEFAULT_OPEN_DELAY,
156
+ skipDelayDuration?: number = DEFAULT_SKIP_DELAY,
157
+ ) {
158
+ const group = useDelayGroup(skipDelayDuration);
159
+ const scope = useMemo(() => ({ delayDuration, group }), [delayDuration, group]);
160
+
161
+ return <TooltipScopeContext.Provider value={scope}>{children}</TooltipScopeContext.Provider>;
162
+ }
163
+
164
+ /**
165
+ * One tooltip and its trigger.
166
+ *
167
+ * `openDelay` falls back to the enclosing `Tooltip.Provider`'s
168
+ * `delayDuration`, and to 700ms when there is no provider — a tooltip on its
169
+ * own is a complete tooltip and needs nothing around it.
170
+ */
171
+ export component TooltipRoot(
172
+ children: React.Node,
173
+ closeDelay?: number = DEFAULT_CLOSE_DELAY,
174
+ defaultOpen?: boolean = false,
175
+ onOpenChange?: (open: boolean) => void,
176
+ open?: boolean,
177
+ openDelay?: number,
178
+ ) {
179
+ const base = useId();
180
+ const scope = useContext(TooltipScopeContext);
181
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
182
+ const triggerRef = useRef<HTMLElement | null>(null);
183
+ const dismissed = useRef(false);
184
+ const intent = useHoverIntent(setOpen);
185
+ const own = openDelay ?? scope?.delayDuration ?? DEFAULT_OPEN_DELAY;
186
+ const group = scope?.group;
187
+
188
+ // What this tooltip last told the group, so that mounting closed says
189
+ // nothing — a page of six tooltips would otherwise open six skip windows
190
+ // before the reader had touched anything.
191
+ const told = useRef(false);
192
+ useEffect(() => {
193
+ if (group == null || told.current === isOpen) {
194
+ return;
195
+ }
196
+ told.current = isOpen;
197
+ if (isOpen) {
198
+ group.opened();
199
+ } else {
200
+ group.closed();
201
+ }
202
+ }, [group, isOpen]);
203
+
204
+ // A tooltip taken away while it was showing has to say so, or the group is
205
+ // left believing one is open and answers every later hover instantly.
206
+ useEffect(
207
+ () => () => {
208
+ if (told.current) {
209
+ told.current = false;
210
+ group?.closed();
211
+ }
212
+ },
213
+ [group],
214
+ );
215
+
216
+ const state = useMemo(
217
+ () => ({
218
+ base,
219
+ closeDelay,
220
+ dismissed,
221
+ intent,
222
+ open: isOpen,
223
+ openDelay: () => group?.delayFor(own) ?? own,
224
+ setOpen,
225
+ triggerRef,
226
+ }),
227
+ [base, closeDelay, group, intent, isOpen, own, setOpen],
228
+ );
229
+
230
+ return <TooltipContext.Provider value={state}>{children}</TooltipContext.Provider>;
231
+ }
232
+
233
+ /**
234
+ * What the tooltip describes.
235
+ *
236
+ * A `<button>` by default, and whatever the caller renders when they pass
237
+ * `render` — a link, a menu item, an icon button of their own. Either way it
238
+ * has to be something the keyboard can reach, and `useFocusableTrigger` refuses
239
+ * anything else: a tooltip on a `<span>` is a tooltip only a mouse can find,
240
+ * and it looks perfect in the markup.
241
+ *
242
+ * Every handler is attached to the element rather than passed as a prop, so a
243
+ * `render` function that forgets to spread something still gets a working
244
+ * tooltip; see the module header.
245
+ */
246
+ export component TooltipTrigger(
247
+ children?: React.Node,
248
+ render?: (props: Rest) => React.Node,
249
+ ...rest: Rest
250
+ ) {
251
+ const tooltip = useTooltip("Tooltip.Trigger");
252
+ const { closeDelay, dismissed, intent, openDelay, setOpen, triggerRef } = tooltip;
253
+ useFocusableTrigger(triggerRef, "Tooltip.Trigger");
254
+
255
+ // Whether the pointer put focus here. A click focuses the button, and
256
+ // reopening the tooltip the click just dismissed would put it back over the
257
+ // thing the reader pressed — so a focus that arrived with a press opens
258
+ // nothing, and the next one, which is the keyboard's, does.
259
+ const pressed = useRef(false);
260
+
261
+ useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
262
+ // A tap is not a hover. See the module header: opening here is what eats
263
+ // the tap the control was there to receive.
264
+ if (event.pointerType === "touch" || dismissed.current) {
265
+ return;
266
+ }
267
+ intent.openAfter(openDelay());
268
+ });
269
+ useEventListener(triggerRef, "pointerleave", () => {
270
+ // Leaving is what makes a dismissal stop applying: coming back is a fresh
271
+ // gesture and deserves a fresh answer.
272
+ dismissed.current = false;
273
+ intent.closeAfter(closeDelay);
274
+ });
275
+ useEventListener(triggerRef, "pointerdown", () => {
276
+ pressed.current = true;
277
+ intent.cancel();
278
+ setOpen(false);
279
+ });
280
+ useEventListener(triggerRef, "focusin", () => {
281
+ if (pressed.current) {
282
+ pressed.current = false;
283
+ return;
284
+ }
285
+ if (dismissed.current) {
286
+ return;
287
+ }
288
+ // No delay: the reader has already said what they want by arriving here.
289
+ intent.openAfter(0);
290
+ });
291
+ useEventListener(triggerRef, "focusout", () => {
292
+ pressed.current = false;
293
+ dismissed.current = false;
294
+ intent.closeAfter(0);
295
+ });
296
+
297
+ // Annotated because this one is not written inside a `ref={...}`, and there
298
+ // is nothing else here for Flow to infer the element's type from.
299
+ const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
300
+ triggerRef.current = element;
301
+ });
302
+ // Only while it is there. `aria-describedby` pointing at an element that has
303
+ // been removed is the dangling reference this package keeps coming back to.
304
+ const ours = {
305
+ "aria-describedby": tooltip.open ? `${tooltip.base}-body` : undefined,
306
+ ref: attach,
307
+ };
308
+
309
+ if (render != null) {
310
+ return render(withProps(withoutComposed(rest, ["ref"]), ours));
311
+ }
312
+
313
+ return (
314
+ <button
315
+ {...withoutComposed(rest, ["ref"])}
316
+ aria-describedby={ours["aria-describedby"]}
317
+ ref={attach}
318
+ type="button"
319
+ >
320
+ {children}
321
+ </button>
322
+ );
323
+ }
324
+
325
+ /**
326
+ * The tooltip itself.
327
+ *
328
+ * Hoverable, which is the clause every hand-written tooltip fails: the pointer
329
+ * arriving here calls off the close the trigger scheduled when the pointer
330
+ * left it, so the trip across the gap does not take the content away. It never
331
+ * takes focus, holds no tab stop, and answers `Escape` from wherever focus
332
+ * happens to be.
333
+ */
334
+ export component TooltipBody(
335
+ children: React.Node,
336
+ align?: Align = "center",
337
+ alignOffset?: number = 0,
338
+ avoidCollisions?: boolean = true,
339
+ collisionPadding?: number = 0,
340
+ side?: Side = "top",
341
+ sideOffset?: number = 0,
342
+ ...rest: Rest
343
+ ) {
344
+ const tooltip = useTooltip("Tooltip.Body");
345
+ const { closeDelay, intent, open, triggerRef } = tooltip;
346
+ const bodyRef = useRef<HTMLElement | null>(null);
347
+ const close = useStableCallback(() => {
348
+ // `Escape` dismisses it *and* keeps it dismissed while the reader is still
349
+ // on the trigger. Without the flag the pointer that is still resting there
350
+ // — or, for a hover card, the focus it hands back — reopens it at once,
351
+ // and the key does nothing a reader can see.
352
+ tooltip.dismissed.current = true;
353
+ intent.cancel();
354
+ tooltip.setOpen(false);
355
+ });
356
+
357
+ const anchored = useAnchor({
358
+ align,
359
+ alignOffset,
360
+ anchorRef: triggerRef,
361
+ avoidCollisions,
362
+ collisionPadding,
363
+ open,
364
+ overlayRef: bodyRef,
365
+ side,
366
+ sideOffset,
367
+ });
368
+
369
+ useDismissOnEscape(open, bodyRef, close);
370
+
371
+ // Keyed on `open` rather than written with `useEventListener`, and the
372
+ // difference is load-bearing: that hook reads its target once, when it
373
+ // attaches, and this element does not exist until the tooltip opens. It is
374
+ // the same trap `select.js` documents about its outside-press listener.
375
+ useEffect(() => {
376
+ const body = bodyRef.current;
377
+ if (!open || body == null) {
378
+ return;
379
+ }
380
+ const stay = () => intent.cancel();
381
+ const go = () => intent.closeAfter(closeDelay);
382
+ body.addEventListener("pointerenter", stay);
383
+ body.addEventListener("pointerleave", go);
384
+ return () => {
385
+ body.removeEventListener("pointerenter", stay);
386
+ body.removeEventListener("pointerleave", go);
387
+ };
388
+ }, [open, intent, closeDelay]);
389
+
390
+ if (!open) {
391
+ return null;
392
+ }
393
+
394
+ return (
395
+ <div
396
+ {...withoutComposed(rest, ["ref"])}
397
+ data-align={anchored.align}
398
+ data-side={anchored.side}
399
+ data-state="open"
400
+ id={`${tooltip.base}-body`}
401
+ ref={composeRefs(rest.ref, (element) => {
402
+ bodyRef.current = element;
403
+ })}
404
+ role="tooltip"
405
+ // No `tabIndex`. A tooltip the keyboard can land in is a stop the reader
406
+ // did not ask for and cannot leave the way they expect.
407
+ >
408
+ {children}
409
+ </div>
410
+ );
411
+ }