@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/hover-card.js ADDED
@@ -0,0 +1,334 @@
1
+ // @flow
2
+ //
3
+ // A hover card: the preview a name expands into, taken seriously.
4
+ //
5
+ // It is a tooltip's sibling and it is not a tooltip, and the difference is what
6
+ // is inside. A tooltip holds a phrase and must never be focusable; a hover card
7
+ // holds an avatar, a paragraph and two links, and every one of those has to be
8
+ // reachable — so the pointer has to be able to get there and so does `Tab`.
9
+ //
10
+ // That makes SC 1.4.13's hoverable clause the whole component rather than a
11
+ // detail of it: the gap between a name and the card that describes it takes a
12
+ // moment to cross with a mouse, longer with a trackpad, and much longer for a
13
+ // reader magnifying the screen. Closing on `pointerleave` snatches it away
14
+ // mid-journey. `internal/hover-intent.js` is the delay that stops that, shared
15
+ // with `tooltip.js` so the two cannot drift.
16
+ //
17
+ // # What it is not
18
+ //
19
+ // **Not a dialog.** It carries no `role="dialog"` and no `aria-modal`: nothing
20
+ // about it is modal, focus is not moved into it when it opens, and announcing a
21
+ // dialog a reader never asked for is worse than announcing nothing. Its content
22
+ // is in the document immediately after its trigger, so the reading order
23
+ // carries it and `Tab` reaches it — which is the whole of what a keyboard
24
+ // reader needs from it.
25
+ //
26
+ // **Not described by `aria-describedby`.** A card of links flattened into one
27
+ // description string is a sentence nobody can act on: the links stop being
28
+ // links. `tooltip.js` describes its trigger because a tooltip is a phrase; this
29
+ // does not, because this is not.
30
+ //
31
+ // # Touch
32
+ //
33
+ // It does not open on touch, for the reason `tooltip.js` gives at more length:
34
+ // there is no hover on a touch screen, and the only gesture left is the tap the
35
+ // trigger itself needs. A hover card is therefore an *enrichment* — the link
36
+ // under it must go somewhere useful on its own, because a reader on a phone
37
+ // will only ever get the link.
38
+
39
+ "use client";
40
+
41
+ import * as React from "@uniflowed/react";
42
+ import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
43
+ import { useEventListener } from "@uniflowed/hooks/dom";
44
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
45
+
46
+ import type { Align, Side } from "./internal/anchor.js";
47
+ import type { HoverIntent } from "./internal/hover-intent.js";
48
+ import type { Rest } from "./internal/merge-props.js";
49
+ import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
50
+ import {
51
+ DEFAULT_CLOSE_DELAY,
52
+ DEFAULT_OPEN_DELAY,
53
+ useDismissOnEscape,
54
+ useFocusableTrigger,
55
+ useHoverIntent,
56
+ } from "./internal/hover-intent.js";
57
+ import { useAnchor } from "./internal/anchor.js";
58
+ import { useControlled } from "./internal/controlled-state.js";
59
+
60
+ export type { Align, Side } from "./internal/anchor.js";
61
+
62
+ type HoverCardState = {|
63
+ readonly base: string,
64
+ readonly open: boolean,
65
+ readonly setOpen: (open: boolean) => void,
66
+ readonly triggerRef: { current: HTMLElement | null },
67
+ readonly intent: HoverIntent,
68
+ readonly openDelay: number,
69
+ readonly closeDelay: number,
70
+ /** Whether `Escape` has dismissed it; see `tooltip.js`, which shares the rule. */
71
+ readonly dismissed: { current: boolean },
72
+ |};
73
+
74
+ const HoverCardContext: React.Context<HoverCardState | null> = createContext(null);
75
+
76
+ hook useHoverCard(part: string): HoverCardState {
77
+ const state = useContext(HoverCardContext);
78
+ if (state == null) {
79
+ throw new Error(`${part} must be rendered inside a HoverCard.Root`);
80
+ }
81
+ return state;
82
+ }
83
+
84
+ /**
85
+ * A hover card and the thing it previews.
86
+ *
87
+ * `closeDelay` is longer than a tooltip's would need to be on purpose: it is
88
+ * the time the reader has to reach the card, and a card holding links is a card
89
+ * they are reaching for.
90
+ */
91
+ export component HoverCardRoot(
92
+ children: React.Node,
93
+ closeDelay?: number = DEFAULT_CLOSE_DELAY,
94
+ defaultOpen?: boolean = false,
95
+ onOpenChange?: (open: boolean) => void,
96
+ open?: boolean,
97
+ openDelay?: number = DEFAULT_OPEN_DELAY,
98
+ ) {
99
+ const base = useId();
100
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
101
+ const triggerRef = useRef<HTMLElement | null>(null);
102
+ const dismissed = useRef(false);
103
+ const intent = useHoverIntent(setOpen);
104
+
105
+ const state = useMemo(
106
+ () => ({
107
+ base,
108
+ closeDelay,
109
+ dismissed,
110
+ intent,
111
+ open: isOpen,
112
+ openDelay,
113
+ setOpen,
114
+ triggerRef,
115
+ }),
116
+ [base, closeDelay, intent, isOpen, openDelay, setOpen],
117
+ );
118
+
119
+ return <HoverCardContext.Provider value={state}>{children}</HoverCardContext.Provider>;
120
+ }
121
+
122
+ /**
123
+ * What the card is about, which is usually a link.
124
+ *
125
+ * `render` is how it becomes one: `<HoverCard.Trigger render={(props) => <a
126
+ * href={profile} {...props}>@ada</a>} />`. Whatever it ends up as has to be
127
+ * reachable by keyboard, and `useFocusableTrigger` refuses anything else —
128
+ * a hover card on a `<span>` is one a keyboard reader can never see.
129
+ */
130
+ export component HoverCardTrigger(
131
+ children?: React.Node,
132
+ render?: (props: Rest) => React.Node,
133
+ ...rest: Rest
134
+ ) {
135
+ const card = useHoverCard("HoverCard.Trigger");
136
+ const { closeDelay, dismissed, intent, openDelay, triggerRef } = card;
137
+ useFocusableTrigger(triggerRef, "HoverCard.Trigger");
138
+
139
+ // A press focuses the trigger, and a card opening under the reader's own
140
+ // click would cover what they just went to. See `tooltip.js`.
141
+ const pressed = useRef(false);
142
+
143
+ useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
144
+ if (event.pointerType === "touch" || dismissed.current) {
145
+ return;
146
+ }
147
+ intent.openAfter(openDelay);
148
+ });
149
+ useEventListener(triggerRef, "pointerleave", () => {
150
+ dismissed.current = false;
151
+ intent.closeAfter(closeDelay);
152
+ });
153
+ useEventListener(triggerRef, "pointerdown", () => {
154
+ pressed.current = true;
155
+ intent.cancel();
156
+ });
157
+ useEventListener(triggerRef, "focusin", () => {
158
+ if (pressed.current) {
159
+ pressed.current = false;
160
+ return;
161
+ }
162
+ // The focus a dismissed card hands *back* to this trigger must not reopen
163
+ // it, which is the whole reason the flag exists: without it `Escape` closes
164
+ // the card, focus returns here, and the card comes straight back.
165
+ if (dismissed.current) {
166
+ return;
167
+ }
168
+ // A reader who tabbed here has said what they want; only the pointer is
169
+ // guessed at, so only the pointer waits.
170
+ intent.openAfter(0);
171
+ });
172
+ useEventListener(triggerRef, "focusout", () => {
173
+ pressed.current = false;
174
+ dismissed.current = false;
175
+ // `closeAfter` rather than a close, and this is where the delay earns its
176
+ // keep a second time: `Tab` from the trigger *into* the card is a leave
177
+ // followed immediately by an arrival, and the card's own `focusin` calls
178
+ // this off before it runs. A `closeDelay` of nought would close the card
179
+ // in the instant the reader reached it, which is why the default is not
180
+ // nought and why a caller who sets one should not set that.
181
+ intent.closeAfter(closeDelay);
182
+ });
183
+
184
+ // Annotated because this one is not written inside a `ref={...}`, and there
185
+ // is nothing else here for Flow to infer the element's type from.
186
+ const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
187
+ triggerRef.current = element;
188
+ });
189
+
190
+ if (render != null) {
191
+ return render(withProps(withoutComposed(rest, ["ref"]), { ref: attach }));
192
+ }
193
+
194
+ return (
195
+ <button {...withoutComposed(rest, ["ref"])} ref={attach} type="button">
196
+ {children}
197
+ </button>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * The card.
203
+ *
204
+ * Stays while the pointer is over it and while focus is inside it, which are
205
+ * the same rule applied to the two ways a reader can be in it. `Escape` closes
206
+ * it from either — and when focus was inside, focus goes back to the trigger,
207
+ * because a card that took its own links away and left focus on `<body>` would
208
+ * send the reader back to the top of the page.
209
+ */
210
+ export component HoverCardBody(
211
+ children: React.Node,
212
+ align?: Align = "center",
213
+ alignOffset?: number = 0,
214
+ avoidCollisions?: boolean = true,
215
+ collisionPadding?: number = 0,
216
+ side?: Side = "bottom",
217
+ sideOffset?: number = 0,
218
+ ...rest: Rest
219
+ ) {
220
+ const card = useHoverCard("HoverCard.Body");
221
+ const { closeDelay, intent, open, triggerRef } = card;
222
+ const bodyRef = useRef<HTMLElement | null>(null);
223
+ // Whether the reader is *in* the card, as opposed to over it. It decides one
224
+ // thing and it cannot be asked afterwards: a card closed while it held focus
225
+ // has to hand focus back, and by the time the effect below is cleaned up the
226
+ // element is gone from the document and `activeElement` has already fallen to
227
+ // `<body>` — so the answer is kept while it is still true.
228
+ const held = useRef(false);
229
+ const close = useStableCallback(() => {
230
+ card.dismissed.current = true;
231
+ intent.cancel();
232
+ card.setOpen(false);
233
+ });
234
+
235
+ const anchored = useAnchor({
236
+ align,
237
+ alignOffset,
238
+ anchorRef: triggerRef,
239
+ avoidCollisions,
240
+ collisionPadding,
241
+ open,
242
+ overlayRef: bodyRef,
243
+ side,
244
+ sideOffset,
245
+ });
246
+
247
+ useDismissOnEscape(open, bodyRef, close);
248
+
249
+ // Keyed on `open`, because the element does not exist until then; see
250
+ // `tooltip.js` for why `useEventListener` cannot be used here.
251
+ useEffect(() => {
252
+ const body = bodyRef.current;
253
+ if (!open || body == null) {
254
+ return;
255
+ }
256
+ const stay = () => intent.cancel();
257
+ const go = () => intent.closeAfter(closeDelay);
258
+ const arrived = () => {
259
+ held.current = true;
260
+ stay();
261
+ };
262
+ const gone = () => {
263
+ held.current = false;
264
+ go();
265
+ };
266
+ body.addEventListener("pointerenter", stay);
267
+ body.addEventListener("pointerleave", go);
268
+ // `focusin` and `focusout` rather than `focus` and `blur`: the pair that
269
+ // bubbles is the one that hears a reader moving between two links *inside*
270
+ // the card, where the leave is immediately followed by an arrival and the
271
+ // scheduled close is called off before it runs.
272
+ body.addEventListener("focusin", arrived);
273
+ body.addEventListener("focusout", gone);
274
+
275
+ return () => {
276
+ body.removeEventListener("pointerenter", stay);
277
+ body.removeEventListener("pointerleave", go);
278
+ body.removeEventListener("focusin", arrived);
279
+ body.removeEventListener("focusout", gone);
280
+ };
281
+ }, [open, intent, closeDelay, triggerRef]);
282
+
283
+ // Focus goes back to the trigger when the card *closes* under the reader's
284
+ // focus, which is what `Escape` does: focus was on a link that no longer
285
+ // exists, and leaving it on `<body>` sends the reader back to the top of the
286
+ // page. A card that closed because the pointer left, with focus somewhere
287
+ // else entirely, has no business moving it.
288
+ //
289
+ // Its own effect, keyed on `open` alone. It used to live in the cleanup of
290
+ // the listener effect above, which runs whenever any of that effect's
291
+ // dependencies change — and `closeDelay` is a caller's prop. A caller
292
+ // changing it while the card was open with focus inside pulled focus off the
293
+ // link the reader was on, and the effect then re-attached with `held` reset.
294
+ useEffect(() => {
295
+ if (open) {
296
+ return;
297
+ }
298
+ if (held.current) {
299
+ held.current = false;
300
+ triggerRef.current?.focus?.();
301
+ }
302
+ }, [open, triggerRef]);
303
+
304
+ // And on unmount, which the effect above cannot see: a card removed while
305
+ // the reader is inside it leaves focus on a node that is gone.
306
+ useEffect(
307
+ () => () => {
308
+ if (held.current) {
309
+ held.current = false;
310
+ triggerRef.current?.focus?.();
311
+ }
312
+ },
313
+ [triggerRef],
314
+ );
315
+
316
+ if (!open) {
317
+ return null;
318
+ }
319
+
320
+ return (
321
+ <div
322
+ {...withoutComposed(rest, ["ref"])}
323
+ data-align={anchored.align}
324
+ data-side={anchored.side}
325
+ data-state="open"
326
+ id={`${card.base}-body`}
327
+ ref={composeRefs(rest.ref, (element) => {
328
+ bodyRef.current = element;
329
+ })}
330
+ >
331
+ {children}
332
+ </div>
333
+ );
334
+ }