@uniflowed/ui 0.0.0-alpha.8 → 0.0.0-alpha.9
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/dialog.js +1 -22
- package/hover-card.js +334 -0
- package/index.js +99 -5
- package/internal/anchor.js +500 -0
- package/internal/focus.js +64 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +32 -0
- package/package.json +7 -4
- package/popover.js +326 -0
- package/tooltip.js +411 -0
package/dialog.js
CHANGED
|
@@ -53,6 +53,7 @@ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
|
53
53
|
|
|
54
54
|
import type { Rest } from "./internal/merge-props.js";
|
|
55
55
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
56
|
+
import { focusable } from "./internal/focus.js";
|
|
56
57
|
import { useControlled } from "./internal/controlled-state.js";
|
|
57
58
|
|
|
58
59
|
type DialogState = {|
|
|
@@ -370,28 +371,6 @@ export component DialogClose(children: React.Node, ...rest: Rest) {
|
|
|
370
371
|
);
|
|
371
372
|
}
|
|
372
373
|
|
|
373
|
-
/**
|
|
374
|
-
* The focus stops inside an element, in document order.
|
|
375
|
-
*
|
|
376
|
-
* Disabled controls and `tabindex="-1"` are excluded because the browser
|
|
377
|
-
* excludes them, and anything inside `[hidden]`, `[inert]` or `aria-hidden` is
|
|
378
|
-
* excluded because a reader cannot reach it.
|
|
379
|
-
*/
|
|
380
|
-
function focusable(root: HTMLElement): Array<HTMLElement> {
|
|
381
|
-
const selector =
|
|
382
|
-
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
383
|
-
return Array.from(root.querySelectorAll(selector)).filter(
|
|
384
|
-
(element: $FlowFixMe) =>
|
|
385
|
-
// All three attributes hide a whole subtree, so all three are checked on
|
|
386
|
-
// the ancestors. Reading `aria-hidden` off the element alone returned a
|
|
387
|
-
// button inside `<div aria-hidden="true">` as a focus stop, and the trap
|
|
388
|
-
// then moved focus to a control no screen reader exposes.
|
|
389
|
-
element.closest("[hidden]") == null &&
|
|
390
|
-
element.closest("[inert]") == null &&
|
|
391
|
-
element.closest('[aria-hidden="true"]') == null,
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
374
|
/**
|
|
396
375
|
* Take everything outside `element` out of the page, and give it back.
|
|
397
376
|
*
|
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
|
+
}
|
package/index.js
CHANGED
|
@@ -112,24 +112,36 @@
|
|
|
112
112
|
// role, which is why it is beside one rather than with the layout.
|
|
113
113
|
// - `table.js` and `pagination.js` — the sort that is announced, the selection
|
|
114
114
|
// that can be mixed, and the rows a page is not showing.
|
|
115
|
+
// - `popover.js`, `tooltip.js` and `hover-card.js` — the three anchored
|
|
116
|
+
// overlays, which are one component seen from three distances: one you
|
|
117
|
+
// click, one you hover, and one you hover and then read. They are three
|
|
118
|
+
// modules because what a reader is told differs in every one — a popover is
|
|
119
|
+
// a dialog that is not modal, a tooltip describes its trigger and may never
|
|
120
|
+
// take focus, a hover card is neither and holds links — and because a flag
|
|
121
|
+
// selecting between them would be one flag every behaviour had to read.
|
|
115
122
|
//
|
|
116
123
|
// Every name below is exported from one of those, so a consumer may import
|
|
117
124
|
// `@uniflowed/ui` or `@uniflowed/ui/dialog` and get the same thing. The split
|
|
118
125
|
// is by primitive because that is the unit a reader looks for, the unit a
|
|
119
126
|
// bundler drops, and the unit the WAI-ARIA practices are written in.
|
|
120
127
|
//
|
|
121
|
-
// `internal/` holds
|
|
128
|
+
// `internal/` holds nine modules and nothing else, each a rule the primitives
|
|
122
129
|
// must apply identically and a consumer must not be able to apply differently:
|
|
123
130
|
// `merge-props.js` (the caller's props go on first, the component's semantics
|
|
124
131
|
// last), `controlled-state.js` (what "controlled" means here),
|
|
125
132
|
// `roving-focus.js` (how a set of items is found and moved between),
|
|
126
133
|
// `disclosure.js` (how a button says whether a region is showing, and how a
|
|
127
134
|
// closed region stays findable), `form-value.js` (what a `<form>` submits for a
|
|
128
|
-
// control the browser has never heard of),
|
|
135
|
+
// control the browser has never heard of), `range.js` (the arithmetic that
|
|
129
136
|
// keeps `aria-valuemin`, `aria-valuemax` and `aria-valuenow` true about each
|
|
130
|
-
// other).
|
|
131
|
-
//
|
|
132
|
-
//
|
|
137
|
+
// other), `anchor.js` (where an overlay goes, and what it does when it does not
|
|
138
|
+
// fit where it was asked to go), `focus.js` (which elements a reader can reach,
|
|
139
|
+
// which a focus trap and a popover want opposite things from), and
|
|
140
|
+
// `hover-intent.js` (what WCAG requires of content shown on hover or focus,
|
|
141
|
+
// which is three clauses and one mechanism). Each says in its own header why it
|
|
142
|
+
// is unreachable rather than exported. There is no `internal/props.js`-shaped
|
|
143
|
+
// bag of helpers: a module that cannot say what it is about does not belong in
|
|
144
|
+
// this package.
|
|
133
145
|
|
|
134
146
|
import {
|
|
135
147
|
AccordionContent,
|
|
@@ -161,6 +173,7 @@ import {
|
|
|
161
173
|
DialogTrigger,
|
|
162
174
|
} from "./dialog.js";
|
|
163
175
|
import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from "./field.js";
|
|
176
|
+
import { HoverCardBody, HoverCardRoot, HoverCardTrigger } from "./hover-card.js";
|
|
164
177
|
import {
|
|
165
178
|
MenuBody,
|
|
166
179
|
MenuGroup,
|
|
@@ -187,6 +200,7 @@ import {
|
|
|
187
200
|
PaginationPrevious,
|
|
188
201
|
PaginationRoot,
|
|
189
202
|
} from "./pagination.js";
|
|
203
|
+
import { PopoverBody, PopoverRoot, PopoverTrigger } from "./popover.js";
|
|
190
204
|
import { Progress } from "./progress.js";
|
|
191
205
|
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from "./radio-group.js";
|
|
192
206
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./resizable.js";
|
|
@@ -230,9 +244,14 @@ import {
|
|
|
230
244
|
} from "./toast.js";
|
|
231
245
|
import { Toggle } from "./toggle.js";
|
|
232
246
|
import { ToggleGroupItem, ToggleGroupRoot } from "./toggle-group.js";
|
|
247
|
+
import { TooltipBody, TooltipProvider, TooltipRoot, TooltipTrigger } from "./tooltip.js";
|
|
233
248
|
|
|
234
249
|
export type { AccordionType } from "./accordion.js";
|
|
235
250
|
export type { ActivationMode } from "./tabs.js";
|
|
251
|
+
// Where an anchored overlay opens, for a caller who holds one in a variable or
|
|
252
|
+
// a prop of their own. Unions rather than strings, so `side="botom"` is a type
|
|
253
|
+
// error at the call rather than an overlay that quietly opens somewhere else.
|
|
254
|
+
export type { Align, Side } from "./popover.js";
|
|
236
255
|
export type { Sort } from "./table.js";
|
|
237
256
|
export type { Notification, ToastChanges, ToastOptions, Urgency } from "./toast.js";
|
|
238
257
|
export type { ToggleGroupType } from "./toggle-group.js";
|
|
@@ -526,6 +545,81 @@ export const Select = {
|
|
|
526
545
|
Separator: SelectSeparator,
|
|
527
546
|
};
|
|
528
547
|
|
|
548
|
+
/**
|
|
549
|
+
* A dialog that is not modal, anchored to the button that opened it.
|
|
550
|
+
*
|
|
551
|
+
* Focus moves in, `Escape` closes it and gives focus back, and `Tab` *leaves* —
|
|
552
|
+
* the page behind a popover is still there, still scrollable and still
|
|
553
|
+
* tabbable, which is every way in which it is not a `Dialog`.
|
|
554
|
+
*
|
|
555
|
+
* <Popover.Root>
|
|
556
|
+
* <Popover.Trigger>Filters</Popover.Trigger>
|
|
557
|
+
* <Popover.Body align="start" side="bottom" sideOffset={8}>
|
|
558
|
+
* <label>
|
|
559
|
+
* Only mine <input type="checkbox" />
|
|
560
|
+
* </label>
|
|
561
|
+
* </Popover.Body>
|
|
562
|
+
* </Popover.Root>
|
|
563
|
+
*
|
|
564
|
+
* `Popover.Body` reports where it ended up as `data-side` and `data-align`, and
|
|
565
|
+
* writes the trigger's width and the room it had as custom properties, so a
|
|
566
|
+
* stylesheet can point an arrow and cap a height without measuring anything.
|
|
567
|
+
*/
|
|
568
|
+
export const Popover = {
|
|
569
|
+
Root: PopoverRoot,
|
|
570
|
+
Trigger: PopoverTrigger,
|
|
571
|
+
Body: PopoverBody,
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* A phrase about a control, on hover and on focus, that WCAG would accept.
|
|
576
|
+
*
|
|
577
|
+
* Dismissible with `Escape`, hoverable — the pointer can travel onto it — and
|
|
578
|
+
* never focusable. It does not open on touch, deliberately, so the trigger must
|
|
579
|
+
* carry its own name for a reader holding a phone.
|
|
580
|
+
*
|
|
581
|
+
* <Tooltip.Provider delayDuration={700} skipDelayDuration={300}>
|
|
582
|
+
* <Tooltip.Root>
|
|
583
|
+
* <Tooltip.Trigger aria-label="Bold">B</Tooltip.Trigger>
|
|
584
|
+
* <Tooltip.Body>Bold (⌘B)</Tooltip.Body>
|
|
585
|
+
* </Tooltip.Root>
|
|
586
|
+
* <Tooltip.Root>
|
|
587
|
+
* <Tooltip.Trigger aria-label="Italic">I</Tooltip.Trigger>
|
|
588
|
+
* <Tooltip.Body>Italic (⌘I)</Tooltip.Body>
|
|
589
|
+
* </Tooltip.Root>
|
|
590
|
+
* </Tooltip.Provider>
|
|
591
|
+
*
|
|
592
|
+
* `Tooltip.Provider` is what makes the second icon in that toolbar answer at
|
|
593
|
+
* once instead of making the reader wait the delay again. A tooltip outside one
|
|
594
|
+
* is a complete tooltip with a delay of its own.
|
|
595
|
+
*/
|
|
596
|
+
export const Tooltip = {
|
|
597
|
+
Provider: TooltipProvider,
|
|
598
|
+
Root: TooltipRoot,
|
|
599
|
+
Trigger: TooltipTrigger,
|
|
600
|
+
Body: TooltipBody,
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The preview a name expands into: hovered, focused, and full of links.
|
|
605
|
+
*
|
|
606
|
+
* Not a tooltip — its contents are reachable, by pointer and by `Tab` — and not
|
|
607
|
+
* a dialog, because nothing about it is modal.
|
|
608
|
+
*
|
|
609
|
+
* <HoverCard.Root>
|
|
610
|
+
* <HoverCard.Trigger render={(props) => <a href="/ada" {...props}>@ada</a>} />
|
|
611
|
+
* <HoverCard.Body>
|
|
612
|
+
* <p>Ada Lovelace</p>
|
|
613
|
+
* <a href="/ada/notes">Notes</a>
|
|
614
|
+
* </HoverCard.Body>
|
|
615
|
+
* </HoverCard.Root>
|
|
616
|
+
*/
|
|
617
|
+
export const HoverCard = {
|
|
618
|
+
Root: HoverCardRoot,
|
|
619
|
+
Trigger: HoverCardTrigger,
|
|
620
|
+
Body: HoverCardBody,
|
|
621
|
+
};
|
|
622
|
+
|
|
529
623
|
/**
|
|
530
624
|
* Notifications, in a live region that was watching before them.
|
|
531
625
|
*
|