@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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// What WCAG requires of anything that appears because a pointer or focus
|
|
4
|
+
// arrived, which is a tooltip and a hover card and nothing else in this
|
|
5
|
+
// package.
|
|
6
|
+
//
|
|
7
|
+
// SC 1.4.13, *Content on Hover or Focus*, is three clauses, and a hand-written
|
|
8
|
+
// tooltip fails all three. They are not a matter of taste and they are not
|
|
9
|
+
// separable, so they live in one module:
|
|
10
|
+
//
|
|
11
|
+
// * **Dismissible** — `Escape` removes it without moving the pointer. The
|
|
12
|
+
// content never holds focus, so the key never reaches it: the listener has
|
|
13
|
+
// to be on the document, which is `useDismissOnEscape` below.
|
|
14
|
+
// * **Hoverable** — the pointer can travel from the trigger onto the content
|
|
15
|
+
// without it vanishing on the way. That is why leaving schedules a close
|
|
16
|
+
// rather than performing one, and why arriving anywhere cancels it. A
|
|
17
|
+
// component that closes on `pointerleave` snatches the content away from a
|
|
18
|
+
// reader who was moving towards it — including every reader who magnifies
|
|
19
|
+
// the screen, for whom the trip is long.
|
|
20
|
+
// * **Persistent** — it stays until it is dismissed or the pointer and focus
|
|
21
|
+
// have both left. A timer that closes it on its own is out.
|
|
22
|
+
//
|
|
23
|
+
// The opening delay is not one of the three clauses; it is what makes the
|
|
24
|
+
// component bearable. A pointer crossing a toolbar enters six triggers on its
|
|
25
|
+
// way somewhere else, and a tooltip that opened on each would be six
|
|
26
|
+
// interruptions. **A delay belongs to the pointer and not to focus**: a reader
|
|
27
|
+
// who tabbed to a control has already said what they want, and making them wait
|
|
28
|
+
// for it is a delay with nothing to prevent.
|
|
29
|
+
//
|
|
30
|
+
// # The clock is a ref, and `useTimeout` is deliberately not used
|
|
31
|
+
//
|
|
32
|
+
// `@uniflowed/hooks/timing` has the hook this looks like it wants, and its
|
|
33
|
+
// contract is not this one: `useTimeout(body, millis)` sets its timer in an
|
|
34
|
+
// effect keyed on `millis`, so asking again for the *same* delay does not
|
|
35
|
+
// restart it. Every interesting sequence here asks twice — enter, leave,
|
|
36
|
+
// enter — and with an open delay equal to the close delay the second request
|
|
37
|
+
// would inherit the first request's deadline and fire early. What is wanted is
|
|
38
|
+
// "restart the clock", which is a command rather than a state, so it is written
|
|
39
|
+
// as one.
|
|
40
|
+
//
|
|
41
|
+
// # Why this is `internal/` and not a subpath
|
|
42
|
+
//
|
|
43
|
+
// It is not a `useHoverIntent` for anybody to build a tooltip with; it is the
|
|
44
|
+
// half of `tooltip.js` and `hover-card.js` that has to be the same in both. A
|
|
45
|
+
// consumer given a copy could build the component that closes on
|
|
46
|
+
// `pointerleave`, which is the failure this exists to prevent.
|
|
47
|
+
|
|
48
|
+
import { useEffect, useMemo, useRef } from "@uniflowed/react";
|
|
49
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
50
|
+
|
|
51
|
+
import { FOCUS_STOPS } from "./focus.js";
|
|
52
|
+
|
|
53
|
+
/** How long a pointer must rest on a trigger before its tooltip opens. */
|
|
54
|
+
export const DEFAULT_OPEN_DELAY: number = 700;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How long the content stays after the pointer leaves.
|
|
58
|
+
*
|
|
59
|
+
* This is the hoverable clause's whole implementation: the gap between a
|
|
60
|
+
* trigger and its overlay takes a moment to cross, and a reader who is crossing
|
|
61
|
+
* it has not left.
|
|
62
|
+
*/
|
|
63
|
+
export const DEFAULT_CLOSE_DELAY: number = 300;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How long after one tooltip closes the next one opens with no delay.
|
|
67
|
+
*
|
|
68
|
+
* A reader who has waited out the delay once has established that they are
|
|
69
|
+
* reading tooltips; the second icon in a toolbar should answer immediately.
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_SKIP_DELAY: number = 300;
|
|
72
|
+
|
|
73
|
+
/** Opening and closing, on a clock that can be restarted or called off. */
|
|
74
|
+
export type HoverIntent = {|
|
|
75
|
+
/** Open after `millis`, or in this tick when that is nought. */
|
|
76
|
+
readonly openAfter: (millis: number) => void,
|
|
77
|
+
/** Close after `millis`, or in this tick when that is nought. */
|
|
78
|
+
readonly closeAfter: (millis: number) => void,
|
|
79
|
+
/** Forget whatever was scheduled. Arriving anywhere calls this first. */
|
|
80
|
+
readonly cancel: () => void,
|
|
81
|
+
|};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A group of tooltips that share one clock.
|
|
85
|
+
*
|
|
86
|
+
* `Tooltip.Provider` holds it; `Tooltip.Root` asks it what delay to use. A
|
|
87
|
+
* tooltip outside a provider never sees one and uses its own delay, which is
|
|
88
|
+
* the behaviour a tooltip on its own has always had.
|
|
89
|
+
*/
|
|
90
|
+
export type DelayGroup = {|
|
|
91
|
+
/** `own`, or nought while the group is inside its skip window. */
|
|
92
|
+
readonly delayFor: (own: number) => number,
|
|
93
|
+
/** Told that a tooltip in the group has opened. */
|
|
94
|
+
readonly opened: () => void,
|
|
95
|
+
/** Told that one has closed, which is what starts the window. */
|
|
96
|
+
readonly closed: () => void,
|
|
97
|
+
|};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A pending open or close, restartable, and cancelled when the component goes.
|
|
101
|
+
*
|
|
102
|
+
* `setOpen` is called with the answer rather than with a toggle, so a schedule
|
|
103
|
+
* that is overtaken by a second one does not leave the component holding the
|
|
104
|
+
* first one's opinion.
|
|
105
|
+
*/
|
|
106
|
+
export hook useHoverIntent(setOpen: (open: boolean) => void): HoverIntent {
|
|
107
|
+
const timer = useRef<TimeoutID | null>(null);
|
|
108
|
+
const change = useStableCallback(setOpen);
|
|
109
|
+
|
|
110
|
+
const cancel = useStableCallback(() => {
|
|
111
|
+
if (timer.current != null) {
|
|
112
|
+
clearTimeout(timer.current);
|
|
113
|
+
timer.current = null;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const schedule = useStableCallback((open: boolean, millis: number) => {
|
|
118
|
+
cancel();
|
|
119
|
+
if (millis <= 0) {
|
|
120
|
+
// In this tick, not in a zero-millisecond timeout. A tooltip that opens
|
|
121
|
+
// on focus, and the second tooltip in a toolbar, both have to be open by
|
|
122
|
+
// the time the event handler returns — a test that has to advance a clock
|
|
123
|
+
// to see them is describing a wait the reader would also have had.
|
|
124
|
+
change(open);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
timer.current = setTimeout(() => {
|
|
128
|
+
timer.current = null;
|
|
129
|
+
change(open);
|
|
130
|
+
}, millis);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// The component can be taken away while a tooltip is waiting to open, and a
|
|
134
|
+
// timer that outlives it sets state on something that is gone.
|
|
135
|
+
useEffect(() => cancel, [cancel]);
|
|
136
|
+
|
|
137
|
+
return useMemo(
|
|
138
|
+
() => ({
|
|
139
|
+
cancel,
|
|
140
|
+
closeAfter: (millis: number) => schedule(false, millis),
|
|
141
|
+
openAfter: (millis: number) => schedule(true, millis),
|
|
142
|
+
}),
|
|
143
|
+
[cancel, schedule],
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The shared clock behind `Tooltip.Provider`.
|
|
149
|
+
*
|
|
150
|
+
* Refs rather than state, and that is the whole design: nothing here is
|
|
151
|
+
* rendered. A group that held "are we skipping" in state would re-render every
|
|
152
|
+
* tooltip in a toolbar twice for each one the pointer passed over, to change
|
|
153
|
+
* nothing anybody can see.
|
|
154
|
+
*
|
|
155
|
+
* The window is open while a tooltip in the group is showing — moving along a
|
|
156
|
+
* toolbar with one already open is the case that must not stutter — and for
|
|
157
|
+
* `skipDelay` after the last one closes.
|
|
158
|
+
*/
|
|
159
|
+
export hook useDelayGroup(skipDelay: number): DelayGroup {
|
|
160
|
+
const skipping = useRef(false);
|
|
161
|
+
const timer = useRef<TimeoutID | null>(null);
|
|
162
|
+
|
|
163
|
+
const stop = useStableCallback(() => {
|
|
164
|
+
if (timer.current != null) {
|
|
165
|
+
clearTimeout(timer.current);
|
|
166
|
+
timer.current = null;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
useEffect(() => stop, [stop]);
|
|
171
|
+
|
|
172
|
+
return useMemo(
|
|
173
|
+
() => ({
|
|
174
|
+
closed: () => {
|
|
175
|
+
stop();
|
|
176
|
+
if (skipDelay <= 0) {
|
|
177
|
+
skipping.current = false;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
skipping.current = true;
|
|
181
|
+
timer.current = setTimeout(() => {
|
|
182
|
+
timer.current = null;
|
|
183
|
+
skipping.current = false;
|
|
184
|
+
}, skipDelay);
|
|
185
|
+
},
|
|
186
|
+
delayFor: (own: number) => (skipping.current ? 0 : own),
|
|
187
|
+
opened: () => {
|
|
188
|
+
// While one is open the group is answering instantly, and the window
|
|
189
|
+
// does not start counting down until it closes.
|
|
190
|
+
stop();
|
|
191
|
+
skipping.current = true;
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
[skipDelay, stop],
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Close on `Escape`, from wherever focus happens to be.
|
|
200
|
+
*
|
|
201
|
+
* On the document, because the content shown on hover holds no focus and a
|
|
202
|
+
* handler on it would never be reached — which is exactly why the hand-written
|
|
203
|
+
* version fails the dismissible clause rather than implementing it wrongly.
|
|
204
|
+
*
|
|
205
|
+
* Capture, and `stopPropagation`, so one `Escape` is one dismissal: a tooltip
|
|
206
|
+
* inside a dialog answers the key itself rather than leaving the reader with a
|
|
207
|
+
* dialog that closed because a tooltip was showing.
|
|
208
|
+
*/
|
|
209
|
+
export hook useDismissOnEscape(
|
|
210
|
+
open: boolean,
|
|
211
|
+
ref: { current: HTMLElement | null },
|
|
212
|
+
onDismiss: () => void,
|
|
213
|
+
): void {
|
|
214
|
+
const dismiss = useStableCallback(onDismiss);
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
const document = ref.current?.ownerDocument;
|
|
218
|
+
if (!open || document == null) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const onKeyDown = (event: $FlowFixMe) => {
|
|
222
|
+
if (event.key !== "Escape") {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
event.stopPropagation();
|
|
226
|
+
dismiss();
|
|
227
|
+
};
|
|
228
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
229
|
+
return () => document.removeEventListener("keydown", onKeyDown, true);
|
|
230
|
+
}, [open, ref, dismiss]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Refuse a trigger the keyboard cannot reach.
|
|
235
|
+
*
|
|
236
|
+
* A tooltip on a `<span>` is a tooltip only a mouse can find, and it looks
|
|
237
|
+
* perfect: the markup is right, the styles are right, and a reader who never
|
|
238
|
+
* touches a mouse is told nothing at all. It is the failure this package exists
|
|
239
|
+
* to make loud, so it is an error rather than a warning — the same answer
|
|
240
|
+
* `useDialog` gives to a part outside its root.
|
|
241
|
+
*
|
|
242
|
+
* Checked in an effect because it is a question about an element, and the
|
|
243
|
+
* element does not exist until one has been committed.
|
|
244
|
+
*/
|
|
245
|
+
export hook useFocusableTrigger(ref: { current: HTMLElement | null }, part: string): void {
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
const element = ref.current;
|
|
248
|
+
if (element == null || element.matches(FOCUS_STOPS)) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
throw new Error(
|
|
252
|
+
`${part} must be something the keyboard can reach: it was rendered onto ` +
|
|
253
|
+
`<${element.tagName.toLowerCase()}>, which is not focusable, so the ` +
|
|
254
|
+
`content would only ever appear for a pointer. Render a button or a ` +
|
|
255
|
+
`link, or give the element a tabindex of 0. A disabled control is not ` +
|
|
256
|
+
`focusable either: use aria-disabled and keep it in the tab order.`,
|
|
257
|
+
);
|
|
258
|
+
}, [ref, part]);
|
|
259
|
+
}
|
package/internal/merge-props.js
CHANGED
|
@@ -146,6 +146,38 @@ export function composeRefs<T>(
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Two sets of props, the component's on top.
|
|
151
|
+
*
|
|
152
|
+
* The same rule as everywhere else in this package, applied where the element
|
|
153
|
+
* is the *caller's* rather than the component's: `Tooltip.Trigger` and
|
|
154
|
+
* `HoverCard.Trigger` hand their attributes to a render function so a caller
|
|
155
|
+
* can put them on a link or a menu item of their own, and the attributes that
|
|
156
|
+
* make the trigger work — the `aria-describedby` naming the content, the ref
|
|
157
|
+
* the content is measured against — have to survive whatever the caller passed
|
|
158
|
+
* alongside them.
|
|
159
|
+
*
|
|
160
|
+
* A spread would say this in one line and cannot be written: Flow declines to
|
|
161
|
+
* compute a type for `{ ...base, name: value }` when `base` has an indexer,
|
|
162
|
+
* because the indexer may overwrite the named key in a way it cannot track.
|
|
163
|
+
* The loop is that spread, with `key` dropped for the reason `withoutComposed`
|
|
164
|
+
* gives.
|
|
165
|
+
*/
|
|
166
|
+
export function withProps(base: Rest, ours: Rest): Rest {
|
|
167
|
+
const merged: { key?: empty, [string]: mixed } = {};
|
|
168
|
+
for (const name of Object.keys(base)) {
|
|
169
|
+
if (name !== "key") {
|
|
170
|
+
merged[name] = base[name];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const name of Object.keys(ours)) {
|
|
174
|
+
if (name !== "key") {
|
|
175
|
+
merged[name] = ours[name];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return merged;
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
/**
|
|
150
182
|
* A caller's props with the handlers and ref removed.
|
|
151
183
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/ui",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.9",
|
|
4
4
|
"description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
"./combobox": "./combobox.js",
|
|
19
19
|
"./dialog": "./dialog.js",
|
|
20
20
|
"./field": "./field.js",
|
|
21
|
+
"./hover-card": "./hover-card.js",
|
|
21
22
|
"./menu": "./menu.js",
|
|
22
23
|
"./navigation-menu": "./navigation-menu.js",
|
|
23
24
|
"./pagination": "./pagination.js",
|
|
25
|
+
"./popover": "./popover.js",
|
|
24
26
|
"./progress": "./progress.js",
|
|
25
27
|
"./radio-group": "./radio-group.js",
|
|
26
28
|
"./resizable": "./resizable.js",
|
|
@@ -31,15 +33,16 @@
|
|
|
31
33
|
"./tabs": "./tabs.js",
|
|
32
34
|
"./toast": "./toast.js",
|
|
33
35
|
"./toggle": "./toggle.js",
|
|
34
|
-
"./toggle-group": "./toggle-group.js"
|
|
36
|
+
"./toggle-group": "./toggle-group.js",
|
|
37
|
+
"./tooltip": "./tooltip.js"
|
|
35
38
|
},
|
|
36
39
|
"files": [
|
|
37
40
|
"*.js",
|
|
38
41
|
"internal"
|
|
39
42
|
],
|
|
40
43
|
"dependencies": {
|
|
41
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
42
|
-
"@uniflowed/react": "0.0.0-alpha.
|
|
44
|
+
"@uniflowed/hooks": "0.0.0-alpha.9",
|
|
45
|
+
"@uniflowed/react": "0.0.0-alpha.9"
|
|
43
46
|
},
|
|
44
47
|
"peerDependencies": {
|
|
45
48
|
"react": ">=19"
|
package/popover.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A popover: a dialog that is not modal, which is the whole of the difference.
|
|
4
|
+
//
|
|
5
|
+
// `dialog.js` is modal and only modal, on purpose — every line of it is a
|
|
6
|
+
// promise that the rest of the page is unavailable. A popover makes the
|
|
7
|
+
// opposite promise, and it has to make it in every one of the same places:
|
|
8
|
+
//
|
|
9
|
+
// * No `aria-modal`, because the page behind is still there.
|
|
10
|
+
// * Nothing is made `inert` and nothing is `aria-hidden`, because a reader
|
|
11
|
+
// may still reach it.
|
|
12
|
+
// * The page is not scroll-locked, because a wheel over a popover scrolling
|
|
13
|
+
// the page behind is what a reader expects from something that did not
|
|
14
|
+
// take the page over.
|
|
15
|
+
// * **`Tab` leaves.** This is the load-bearing one. A trap is what makes a
|
|
16
|
+
// modal dialog safe and what makes a popover a hole a reader falls into:
|
|
17
|
+
// they tabbed in, they tab out, and a component that wraps them back to
|
|
18
|
+
// the first control has taken the page away without ever saying so.
|
|
19
|
+
//
|
|
20
|
+
// So a popover is not a `Dialog` with a flag. A flag would mean every one of
|
|
21
|
+
// the behaviours above reading it, and the failure of the one that forgot would
|
|
22
|
+
// be a dialog that is not modal while announcing that it is — silent, and
|
|
23
|
+
// wrong in the direction that traps people.
|
|
24
|
+
//
|
|
25
|
+
// What it *does* share with a dialog is the part a reader notices when it is
|
|
26
|
+
// missing: focus moves into it when it opens, `Escape` closes it, and focus
|
|
27
|
+
// goes back to the trigger — unless the reader dismissed it by pressing or
|
|
28
|
+
// tabbing somewhere else, in which case it stays where they put it.
|
|
29
|
+
//
|
|
30
|
+
// # Where it goes
|
|
31
|
+
//
|
|
32
|
+
// `internal/anchor.js`, the same as every other overlay here: `side`, `align`,
|
|
33
|
+
// `sideOffset` and `alignOffset` place it against the trigger, it flips and
|
|
34
|
+
// slides to stay on the screen, and it reports where it ended up as `data-side`
|
|
35
|
+
// and `data-align` so a stylesheet can point an arrow without measuring
|
|
36
|
+
// anything itself.
|
|
37
|
+
//
|
|
38
|
+
// # Its name
|
|
39
|
+
//
|
|
40
|
+
// `role="dialog"` needs an accessible name, and a popover has no `Title` part
|
|
41
|
+
// to take one from — shadcn's does not either, and adding one would make the
|
|
42
|
+
// common case (a form, a colour picker, a date picker) carry a heading nobody
|
|
43
|
+
// asked for. So the body is named after the button that opened it, which is
|
|
44
|
+
// true and is what a reader would say the popover is, and a caller who passes
|
|
45
|
+
// `aria-label` or `aria-labelledby` of their own keeps it.
|
|
46
|
+
|
|
47
|
+
"use client";
|
|
48
|
+
|
|
49
|
+
import * as React from "@uniflowed/react";
|
|
50
|
+
import {
|
|
51
|
+
createContext,
|
|
52
|
+
useContext,
|
|
53
|
+
useEffect,
|
|
54
|
+
useId,
|
|
55
|
+
useMemo,
|
|
56
|
+
useRef,
|
|
57
|
+
useState,
|
|
58
|
+
} from "@uniflowed/react";
|
|
59
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
60
|
+
|
|
61
|
+
import type { Align, Side } from "./internal/anchor.js";
|
|
62
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
63
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
64
|
+
import { focusable } from "./internal/focus.js";
|
|
65
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
66
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
67
|
+
import { usePresence } from "./internal/disclosure.js";
|
|
68
|
+
|
|
69
|
+
export type { Align, Side } from "./internal/anchor.js";
|
|
70
|
+
|
|
71
|
+
type PopoverState = {|
|
|
72
|
+
readonly base: string,
|
|
73
|
+
readonly open: boolean,
|
|
74
|
+
readonly setOpen: (open: boolean) => void,
|
|
75
|
+
/** What opened it, where it is anchored, and where focus goes back to. */
|
|
76
|
+
readonly triggerRef: { current: HTMLElement | null },
|
|
77
|
+
/**
|
|
78
|
+
* Whether a trigger is rendered, so the body only names one that exists.
|
|
79
|
+
*
|
|
80
|
+
* A popover opened by `defaultOpen` in a page with no trigger is a real
|
|
81
|
+
* arrangement — a first-run hint pointing at something — and an
|
|
82
|
+
* `aria-labelledby` naming the id that trigger *would* have had makes a
|
|
83
|
+
* screen reader announce nothing at all.
|
|
84
|
+
*/
|
|
85
|
+
readonly triggered: boolean,
|
|
86
|
+
readonly registerTrigger: (present: boolean) => void,
|
|
87
|
+
|};
|
|
88
|
+
|
|
89
|
+
const PopoverContext: React.Context<PopoverState | null> = createContext(null);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The popover a part belongs to.
|
|
93
|
+
*
|
|
94
|
+
* Raising rather than returning null, for the reason `useDialog` gives: a
|
|
95
|
+
* `Popover.Body` outside a root would render a `role="dialog"` that nothing
|
|
96
|
+
* opens, closes or names, and it would look correct.
|
|
97
|
+
*/
|
|
98
|
+
hook usePopover(part: string): PopoverState {
|
|
99
|
+
const state = useContext(PopoverContext);
|
|
100
|
+
if (state == null) {
|
|
101
|
+
throw new Error(`${part} must be rendered inside a Popover.Root`);
|
|
102
|
+
}
|
|
103
|
+
return state;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The popover, open or closed. Uncontrolled unless `open` is given.
|
|
108
|
+
*
|
|
109
|
+
* Renders no element of its own: the trigger and the body are siblings in
|
|
110
|
+
* whatever layout the caller wrote, and a wrapper would put a `<div>` between
|
|
111
|
+
* them for the caller to style around.
|
|
112
|
+
*/
|
|
113
|
+
export component PopoverRoot(
|
|
114
|
+
children: React.Node,
|
|
115
|
+
defaultOpen?: boolean = false,
|
|
116
|
+
open?: boolean,
|
|
117
|
+
onOpenChange?: (open: boolean) => void,
|
|
118
|
+
) {
|
|
119
|
+
const base = useId();
|
|
120
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
121
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
122
|
+
const [triggered, setTriggered] = useState(false);
|
|
123
|
+
|
|
124
|
+
const state = useMemo(
|
|
125
|
+
() => ({
|
|
126
|
+
base,
|
|
127
|
+
open: isOpen,
|
|
128
|
+
registerTrigger: setTriggered,
|
|
129
|
+
setOpen,
|
|
130
|
+
triggerRef,
|
|
131
|
+
triggered,
|
|
132
|
+
}),
|
|
133
|
+
[base, isOpen, setOpen, triggered],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return <PopoverContext.Provider value={state}>{children}</PopoverContext.Provider>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The button that opens the popover, and what it is anchored to.
|
|
141
|
+
*
|
|
142
|
+
* A toggle rather than an opener: pressing the button of an open popover closes
|
|
143
|
+
* it, which is what every disclosure does and what a reader who pressed it by
|
|
144
|
+
* accident expects. The outside-press handler in `Popover.Body` knows the
|
|
145
|
+
* trigger is not "outside" for exactly this reason — closing there and letting
|
|
146
|
+
* this click reopen made the press a no-op that flickered.
|
|
147
|
+
*/
|
|
148
|
+
export component PopoverTrigger(children: React.Node, ...rest: Rest) {
|
|
149
|
+
const popover = usePopover("Popover.Trigger");
|
|
150
|
+
const passed = withoutComposed(rest, ["onClick", "ref"]);
|
|
151
|
+
usePresence(popover.registerTrigger);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
{...passed}
|
|
156
|
+
// Only while it is open: an `aria-controls` naming an element that is not
|
|
157
|
+
// in the document tells a reader there is somewhere to go and then has
|
|
158
|
+
// nowhere to send them.
|
|
159
|
+
aria-controls={popover.open ? `${popover.base}-body` : undefined}
|
|
160
|
+
aria-expanded={popover.open ? "true" : "false"}
|
|
161
|
+
aria-haspopup="dialog"
|
|
162
|
+
id={`${popover.base}-trigger`}
|
|
163
|
+
onClick={composeHandlers(rest.onClick, () => popover.setOpen(!popover.open))}
|
|
164
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
165
|
+
popover.triggerRef.current = element;
|
|
166
|
+
})}
|
|
167
|
+
type="button"
|
|
168
|
+
>
|
|
169
|
+
{children}
|
|
170
|
+
</button>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The popover itself: positioned, focused, dismissible, and not a trap.
|
|
176
|
+
*
|
|
177
|
+
* The three ways out are the three a reader tries. `Escape` closes it and gives
|
|
178
|
+
* focus back to the trigger, because the reader is still where they were. A
|
|
179
|
+
* press outside closes it and leaves focus alone, because they have already
|
|
180
|
+
* moved on. `Tab` past the last control inside closes it for the same reason
|
|
181
|
+
* and leaves focus where the browser put it — which is the behaviour that
|
|
182
|
+
* separates this from a dialog, and the one a copy of `dialog.js` with the
|
|
183
|
+
* `aria-modal` deleted would get wrong.
|
|
184
|
+
*/
|
|
185
|
+
export component PopoverBody(
|
|
186
|
+
children: React.Node,
|
|
187
|
+
align?: Align = "center",
|
|
188
|
+
alignOffset?: number = 0,
|
|
189
|
+
avoidCollisions?: boolean = true,
|
|
190
|
+
collisionPadding?: number = 0,
|
|
191
|
+
side?: Side = "bottom",
|
|
192
|
+
sideOffset?: number = 0,
|
|
193
|
+
...rest: Rest
|
|
194
|
+
) {
|
|
195
|
+
const popover = usePopover("Popover.Body");
|
|
196
|
+
const bodyRef = useRef<HTMLElement | null>(null);
|
|
197
|
+
// Stable, so the effect below depends on `open` and on nothing else. Keyed on
|
|
198
|
+
// `setOpen` it re-ran whenever the caller passed a fresh `onOpenChange`
|
|
199
|
+
// closure — which is every render — and re-running it took focus back from
|
|
200
|
+
// wherever the reader had moved it inside the popover.
|
|
201
|
+
const close = useStableCallback(() => popover.setOpen(false));
|
|
202
|
+
// Set when the reader left rather than closed: a press outside, or a Tab that
|
|
203
|
+
// carried them out. Focus is theirs from then on, and dragging it back to the
|
|
204
|
+
// trigger would undo the thing they just did.
|
|
205
|
+
const left = useRef(false);
|
|
206
|
+
const triggerRef = popover.triggerRef;
|
|
207
|
+
|
|
208
|
+
const anchored = useAnchor({
|
|
209
|
+
align,
|
|
210
|
+
alignOffset,
|
|
211
|
+
anchorRef: triggerRef,
|
|
212
|
+
avoidCollisions,
|
|
213
|
+
collisionPadding,
|
|
214
|
+
open: popover.open,
|
|
215
|
+
overlayRef: bodyRef,
|
|
216
|
+
side,
|
|
217
|
+
sideOffset,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
const body = bodyRef.current;
|
|
222
|
+
if (!popover.open || body == null) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const document = body.ownerDocument;
|
|
226
|
+
const trigger = triggerRef.current;
|
|
227
|
+
// Whatever had focus, which is the trigger for a popover that was opened
|
|
228
|
+
// and the previously focused element for one that opened itself.
|
|
229
|
+
const opener = trigger ?? (document.activeElement as $FlowFixMe);
|
|
230
|
+
|
|
231
|
+
const outside = (target: EventTarget | null): boolean => {
|
|
232
|
+
const node: $FlowFixMe = target;
|
|
233
|
+
// The trigger is outside the body and is not "outside" for this purpose.
|
|
234
|
+
return node != null && !body.contains(node) && !(trigger?.contains(node) ?? false);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const onOutsidePress = (event: Event) => {
|
|
238
|
+
if (!outside(event.target)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
left.current = true;
|
|
242
|
+
close();
|
|
243
|
+
};
|
|
244
|
+
// Capture, so a press is seen even where something below it stops the
|
|
245
|
+
// event — a menu inside the popover, for instance.
|
|
246
|
+
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
247
|
+
|
|
248
|
+
// Tab out is a dismissal, not an escape hatch that leaves a popover open
|
|
249
|
+
// behind the reader: a non-modal overlay whose reader has gone is one they
|
|
250
|
+
// can no longer press Escape at, because Escape is handled where focus is.
|
|
251
|
+
const onFocusMoved = (event: Event) => {
|
|
252
|
+
if (!outside(event.target)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
left.current = true;
|
|
256
|
+
close();
|
|
257
|
+
};
|
|
258
|
+
document.addEventListener("focusin", onFocusMoved, true);
|
|
259
|
+
|
|
260
|
+
// The first thing worth acting on, and the popover itself when it holds
|
|
261
|
+
// nothing focusable, so focus is inside it either way and Escape reaches
|
|
262
|
+
// the handler below.
|
|
263
|
+
(focusable(body)[0] ?? body).focus();
|
|
264
|
+
|
|
265
|
+
return () => {
|
|
266
|
+
document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
267
|
+
document.removeEventListener("focusin", onFocusMoved, true);
|
|
268
|
+
if (left.current) {
|
|
269
|
+
left.current = false;
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// Only when focus would otherwise be lost to `<body>`, the same
|
|
273
|
+
// condition `menu.js` restores under: a popover closed by a control
|
|
274
|
+
// inside it that moved focus somewhere deliberate must not have that
|
|
275
|
+
// undone.
|
|
276
|
+
const active = document.activeElement;
|
|
277
|
+
if (active == null || active === document.body || body.contains(active)) {
|
|
278
|
+
opener?.focus?.();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}, [popover.open, triggerRef, close]);
|
|
282
|
+
|
|
283
|
+
if (!popover.open) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
288
|
+
// Named by its trigger unless the caller said otherwise. Setting it anyway
|
|
289
|
+
// would override an `aria-label` they passed — `aria-labelledby` wins — and
|
|
290
|
+
// leave the popover announced as its button rather than as itself.
|
|
291
|
+
const named = rest["aria-label"] != null || rest["aria-labelledby"] != null;
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div
|
|
295
|
+
// `passed` first, then this component's semantics; see
|
|
296
|
+
// `internal/merge-props.js` for the three bugs that rule is made of.
|
|
297
|
+
{...passed}
|
|
298
|
+
aria-labelledby={named || !popover.triggered ? undefined : `${popover.base}-trigger`}
|
|
299
|
+
data-align={anchored.align}
|
|
300
|
+
data-side={anchored.side}
|
|
301
|
+
data-state="open"
|
|
302
|
+
id={`${popover.base}-body`}
|
|
303
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
304
|
+
if (event.key !== "Escape") {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
event.preventDefault();
|
|
308
|
+
// This popover, not the dialog around it. Two overlays nest in the
|
|
309
|
+
// DOM, so without this one Escape closed both.
|
|
310
|
+
event.stopPropagation();
|
|
311
|
+
close();
|
|
312
|
+
})}
|
|
313
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
314
|
+
bodyRef.current = element;
|
|
315
|
+
})}
|
|
316
|
+
// No `aria-modal`. The page behind a popover is still available, and
|
|
317
|
+
// saying otherwise is the one lie a screen reader cannot see through.
|
|
318
|
+
role="dialog"
|
|
319
|
+
// So the popover can hold focus itself when it contains nothing
|
|
320
|
+
// focusable, and so Escape has somewhere to be heard.
|
|
321
|
+
tabIndex={-1}
|
|
322
|
+
>
|
|
323
|
+
{children}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|