@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/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
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
|
+
}
|