@uniflowed/ui 0.0.0-alpha.8 → 0.1.0
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 +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +560 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +235 -198
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +334 -0
- package/i18n-provider.js +89 -0
- package/index.js +1254 -32
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +565 -0
- package/internal/collection.js +395 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/focus.js +64 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +117 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +316 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -25
- package/pagination.js +34 -22
- package/popover.js +367 -0
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +78 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +112 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +404 -0
- package/tree.js +8 -0
package/internal/disclosure.js
CHANGED
|
@@ -48,6 +48,58 @@
|
|
|
48
48
|
// or re-adds the attribute and this upgrades it again. Nothing here writes an
|
|
49
49
|
// attribute React believes it owns while React believes it.
|
|
50
50
|
//
|
|
51
|
+
// # The height a closed panel would have
|
|
52
|
+
//
|
|
53
|
+
// The third rule, and the one that took a piece of work rather than a line.
|
|
54
|
+
// `height: 0 → var(--uf-collapsible-height)` is the whole of animating a
|
|
55
|
+
// disclosure, and the number in that property is the one thing a stylesheet
|
|
56
|
+
// cannot compute: it is the height the content *would* have, wanted at the
|
|
57
|
+
// moment the panel is still closed, because a transition has to know its
|
|
58
|
+
// destination before it starts.
|
|
59
|
+
//
|
|
60
|
+
// Every obvious way of getting it answers zero. A closed panel is `hidden`, so
|
|
61
|
+
// it has no box: `ResizeObserver` reports `0`, `getBoundingClientRect()` is
|
|
62
|
+
// empty, `scrollHeight` is `0`, and `useElementSize` from
|
|
63
|
+
// `@uniflowed/hooks/dom` measures a hidden element and reports zero — which is
|
|
64
|
+
// exactly the moment the number is wanted. Measuring after the panel opens
|
|
65
|
+
// gives the right number one frame late, which is the jank this removes.
|
|
66
|
+
//
|
|
67
|
+
// So `useMeasuredHeight` lays the panel out without painting it: inline
|
|
68
|
+
// `display`, `position: absolute` and `visibility: hidden`, read, restore. Four
|
|
69
|
+
// things about that are not obvious:
|
|
70
|
+
//
|
|
71
|
+
// * It overrides `display` inline rather than removing `hidden`. The
|
|
72
|
+
// attribute is `useUntilFound`'s and React's; an effect that took it away
|
|
73
|
+
// and put it back would be fighting both of them for one frame, and would
|
|
74
|
+
// lose whichever ran last. An inline declaration beats the user-agent
|
|
75
|
+
// stylesheet's `[hidden] { display: none }` and touches nothing anybody
|
|
76
|
+
// else believes they own.
|
|
77
|
+
// * It sets `content-visibility: visible` in the same pass, because
|
|
78
|
+
// `hidden="until-found"` is `content-visibility: hidden`, which does not lay
|
|
79
|
+
// its subtree out either. Defeating one of the two and not the other
|
|
80
|
+
// measures zero on exactly the panels this package ships.
|
|
81
|
+
// * It sets `height: auto` in the same pass, for the same reason and against
|
|
82
|
+
// the very rule this property exists for. A closed panel is `height: 0` —
|
|
83
|
+
// that is the half of `height: 0 → var(--uf-collapsible-height)` that is
|
|
84
|
+
// always on — so a panel laid out with the stylesheet still applying
|
|
85
|
+
// measures zero and writes `0px` back into the property it was asked to
|
|
86
|
+
// fill. Defeating `display` and not `height` is the same mistake as
|
|
87
|
+
// defeating `display` and not `content-visibility`, one declaration along.
|
|
88
|
+
// * And it pins the width, because taking a box out of flow makes it
|
|
89
|
+
// shrink-to-fit against its containing block — the nearest positioned
|
|
90
|
+
// ancestor, which on most pages is the viewport. Text that wraps to four
|
|
91
|
+
// lines where the panel lives measures one line there. The width it would
|
|
92
|
+
// have in flow is its parent's content box; when that cannot be read the
|
|
93
|
+
// pass leaves the width alone rather than inventing one.
|
|
94
|
+
//
|
|
95
|
+
// It is opt-in, and that is the honest answer to "it should cost nothing on a
|
|
96
|
+
// page that never animates". Whether a stylesheet reads the property is not
|
|
97
|
+
// something the component can ask — `getComputedStyle` on a `display: none`
|
|
98
|
+
// element answers about the declaration, not about whether anybody transitions
|
|
99
|
+
// on it — so the alternative to a prop is a forced layout per panel per render
|
|
100
|
+
// on every page that has a collapsible, animated or not. A forty-section FAQ is
|
|
101
|
+
// forty synchronous layouts nobody asked for.
|
|
102
|
+
//
|
|
51
103
|
// # Why this is `internal/` and not a subpath
|
|
52
104
|
//
|
|
53
105
|
// The same reason `roving-focus.js` gives. These are rules about markup this
|
|
@@ -95,3 +147,152 @@ export hook useUntilFound(ref: { current: HTMLElement | null }, open: boolean):
|
|
|
95
147
|
element.setAttribute("hidden", "until-found");
|
|
96
148
|
}, [ref, open]);
|
|
97
149
|
}
|
|
150
|
+
|
|
151
|
+
/** The custom property a stylesheet transitions a disclosure's height to. */
|
|
152
|
+
export const HEIGHT_PROPERTY: string = "--uf-collapsible-height";
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The width `element` would have in flow, as a CSS length, or nothing.
|
|
156
|
+
*
|
|
157
|
+
* Taking the panel out of flow to measure it costs shrink-to-fit: an absolutely
|
|
158
|
+
* positioned box with `width: auto` is as wide as its *content* wants to be,
|
|
159
|
+
* bounded by its containing block — which is the nearest positioned ancestor
|
|
160
|
+
* and, on most pages, is the viewport rather than the panel's parent. A
|
|
161
|
+
* paragraph that wraps to four lines in a sidebar measures one line there, and
|
|
162
|
+
* the number written into the property is then a height the panel never has.
|
|
163
|
+
*
|
|
164
|
+
* The width it would have in flow is its parent's content box, which is what
|
|
165
|
+
* `getComputedStyle` reports for `width` on a laid-out element. Nothing is
|
|
166
|
+
* returned when the answer is not a length — a parent that is itself
|
|
167
|
+
* `display: none`, or a DOM with no layout to report — and the pass then does
|
|
168
|
+
* what it did before rather than pinning a width it had to guess.
|
|
169
|
+
*/
|
|
170
|
+
function widthInFlow(element: HTMLElement): string | null {
|
|
171
|
+
const parent = element.parentElement;
|
|
172
|
+
const view: $FlowFixMe = element.ownerDocument?.defaultView;
|
|
173
|
+
if (parent == null || view == null || typeof view.getComputedStyle !== "function") {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const width: mixed = view.getComputedStyle(parent).width;
|
|
177
|
+
return typeof width === "string" && width.endsWith("px") ? width : null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The height `element` has, or would have if it were not hidden.
|
|
182
|
+
*
|
|
183
|
+
* The measuring pass, and the reason this module has a header section about it.
|
|
184
|
+
* An open panel is measured where it stands; a closed one is briefly laid out
|
|
185
|
+
* and not painted. Either way this reads layout, which is a synchronous reflow
|
|
186
|
+
* — the caller is the one that decides it is worth paying.
|
|
187
|
+
*
|
|
188
|
+
* # The two things a closed panel is not, and has to be made
|
|
189
|
+
*
|
|
190
|
+
* Laying it out is necessary and is not sufficient, because the stylesheet this
|
|
191
|
+
* property exists for is `height: 0` on the closed panel and
|
|
192
|
+
* `height: var(--uf-collapsible-height)` on the open one. A panel laid out with
|
|
193
|
+
* that rule still applying measures **zero**, the property is written back as
|
|
194
|
+
* `0px`, and the transition has a destination of nothing — the bug the whole
|
|
195
|
+
* hook was written to avoid, arriving through the rule it was written for. So
|
|
196
|
+
* `height: auto` is set inline for the pass, exactly as `display` is: not
|
|
197
|
+
* because the panel wants an inline height but because the author declaration
|
|
198
|
+
* has to be defeated for one synchronous read and put back.
|
|
199
|
+
*
|
|
200
|
+
* `width` is the same argument for the other axis and is `widthInFlow`'s. Both
|
|
201
|
+
* are restored with everything else; a stylesheet is never left fighting an
|
|
202
|
+
* inline declaration this wrote.
|
|
203
|
+
*/
|
|
204
|
+
function heightOf(element: HTMLElement): number {
|
|
205
|
+
if (!element.hasAttribute("hidden")) {
|
|
206
|
+
return element.getBoundingClientRect().height;
|
|
207
|
+
}
|
|
208
|
+
const style = element.style;
|
|
209
|
+
const before = {
|
|
210
|
+
boxSizing: style.boxSizing,
|
|
211
|
+
contentVisibility: style.getPropertyValue("content-visibility"),
|
|
212
|
+
display: style.display,
|
|
213
|
+
height: style.height,
|
|
214
|
+
position: style.position,
|
|
215
|
+
visibility: style.visibility,
|
|
216
|
+
width: style.width,
|
|
217
|
+
};
|
|
218
|
+
// Out of flow and unpainted, so nothing below the panel moves and no frame
|
|
219
|
+
// shows it. The order is not significant; this is all one style
|
|
220
|
+
// recalculation, paid for by the single read below.
|
|
221
|
+
style.display = "block";
|
|
222
|
+
style.setProperty("content-visibility", "visible");
|
|
223
|
+
style.position = "absolute";
|
|
224
|
+
style.visibility = "hidden";
|
|
225
|
+
style.height = "auto";
|
|
226
|
+
const width = widthInFlow(element);
|
|
227
|
+
if (width != null) {
|
|
228
|
+
// `border-box`, because what fills the parent's content width in flow is
|
|
229
|
+
// the panel's margin box rather than its content box: measuring a padded
|
|
230
|
+
// panel content-box wide would make it wider than it will ever be and its
|
|
231
|
+
// text shorter than it will ever wrap to.
|
|
232
|
+
style.boxSizing = "border-box";
|
|
233
|
+
style.width = width;
|
|
234
|
+
}
|
|
235
|
+
const height = element.getBoundingClientRect().height;
|
|
236
|
+
style.boxSizing = before.boxSizing;
|
|
237
|
+
style.display = before.display;
|
|
238
|
+
style.setProperty("content-visibility", before.contentVisibility);
|
|
239
|
+
style.height = before.height;
|
|
240
|
+
style.position = before.position;
|
|
241
|
+
style.visibility = before.visibility;
|
|
242
|
+
style.width = before.width;
|
|
243
|
+
return height;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Keep `HEIGHT_PROPERTY` on a disclosure's panel equal to its content's height.
|
|
248
|
+
*
|
|
249
|
+
* `enabled` is the caller's opt-in; see the module header for why there is one.
|
|
250
|
+
* When it is off this writes nothing and measures nothing, which is what makes
|
|
251
|
+
* a page with no animation pay nothing.
|
|
252
|
+
*
|
|
253
|
+
* Two effects rather than one, because they answer different questions. The
|
|
254
|
+
* first measures on every render, with no dependency array on purpose: what the
|
|
255
|
+
* panel would be worth changes when the *caller* renders different children
|
|
256
|
+
* into it, and a dependency list here would be a claim about when that happens
|
|
257
|
+
* that only the caller could keep — `useFirstItem` in `roving-focus.js` makes
|
|
258
|
+
* the same argument for the same reason. The second watches for a size change
|
|
259
|
+
* no render caused: an image that finished loading, a font that swapped. It
|
|
260
|
+
* only ever fires while the panel is open, because a `display: none` element
|
|
261
|
+
* has no box for a `ResizeObserver` to report on — which is the whole problem
|
|
262
|
+
* this hook exists for, arriving one more time.
|
|
263
|
+
*/
|
|
264
|
+
export hook useMeasuredHeight(ref: { current: HTMLElement | null }, enabled: boolean): void {
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
const element = ref.current;
|
|
267
|
+
if (!enabled || element == null) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const measured = `${String(heightOf(element))}px`;
|
|
271
|
+
// Compared before writing, so a render that changed nothing does not dirty
|
|
272
|
+
// the element's style and invite another style recalculation.
|
|
273
|
+
if (element.style.getPropertyValue(HEIGHT_PROPERTY) !== measured) {
|
|
274
|
+
element.style.setProperty(HEIGHT_PROPERTY, measured);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
const element = ref.current;
|
|
280
|
+
const view = element?.ownerDocument?.defaultView;
|
|
281
|
+
if (!enabled || element == null || view == null) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Read off the window rather than through a local, for the reason
|
|
285
|
+
// `internal/anchor.js` gives where it does the same: a capitalised name
|
|
286
|
+
// holding a constructor is read as a React component by `uf lint`, and the
|
|
287
|
+
// window's own property is the thing being asked about anyway.
|
|
288
|
+
const host: $FlowFixMe = view;
|
|
289
|
+
if (typeof host.ResizeObserver !== "function") {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const sizes = new host.ResizeObserver(() => {
|
|
293
|
+
element.style.setProperty(HEIGHT_PROPERTY, `${String(heightOf(element))}px`);
|
|
294
|
+
});
|
|
295
|
+
sizes.observe(element);
|
|
296
|
+
return () => sizes.disconnect();
|
|
297
|
+
}, [ref, enabled]);
|
|
298
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// What a reader can reach, in the order they reach it.
|
|
4
|
+
//
|
|
5
|
+
// One list, asked for by two components that want opposite things from it.
|
|
6
|
+
// `Dialog.Body` uses it to keep focus *in*: the first and last entries are
|
|
7
|
+
// where `Tab` and `Shift+Tab` wrap. `Popover.Body` uses it to put focus in
|
|
8
|
+
// once and then leaves it alone, because tabbing out of a popover is how a
|
|
9
|
+
// reader leaves one. The list has to be the same list for those two to be
|
|
10
|
+
// describable as different policies over the same fact rather than as two
|
|
11
|
+
// components that disagree about what focusable means.
|
|
12
|
+
//
|
|
13
|
+
// # The selector is the browser's rule, written down
|
|
14
|
+
//
|
|
15
|
+
// A disabled control is out because the browser will not focus one, and
|
|
16
|
+
// `tabindex="-1"` is out because it means "focusable by script, not by Tab" —
|
|
17
|
+
// which is what every roving tab stop in this package uses, so a menu inside a
|
|
18
|
+
// dialog would otherwise report thirty items as focus stops and the trap would
|
|
19
|
+
// wrap between two of them instead of at the dialog's edges.
|
|
20
|
+
//
|
|
21
|
+
// The three ancestor checks are the ones a selector cannot make. `hidden`,
|
|
22
|
+
// `inert` and `aria-hidden="true"` each hide a whole subtree, and reading them
|
|
23
|
+
// off the element alone returned a button inside `<div aria-hidden="true">` as
|
|
24
|
+
// a focus stop — after which the trap moved focus to a control no screen
|
|
25
|
+
// reader exposes and the reader was somewhere they could not be told about.
|
|
26
|
+
//
|
|
27
|
+
// # Why this is `internal/` and not a subpath
|
|
28
|
+
//
|
|
29
|
+
// The same reason `merge-props.js` gives. A public `focusable()` is a
|
|
30
|
+
// general-purpose DOM utility, and a second, weaker copy of one is how two
|
|
31
|
+
// parts of a package come to disagree about which elements exist. What is
|
|
32
|
+
// shipped here is narrower: the definition this package's focus behaviour is
|
|
33
|
+
// written against.
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The elements a browser will move focus to with `Tab`.
|
|
37
|
+
*
|
|
38
|
+
* Exported as the selector as well as through `focusable`, because one
|
|
39
|
+
* question is asked about a single element rather than about a subtree: a
|
|
40
|
+
* tooltip's trigger has to *be* one of these or the tooltip is one only a mouse
|
|
41
|
+
* can reach, and `element.matches(FOCUS_STOPS)` is that question.
|
|
42
|
+
*/
|
|
43
|
+
export const FOCUS_STOPS: string =
|
|
44
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The focus stops inside an element, in document order.
|
|
48
|
+
*
|
|
49
|
+
* Document order rather than mount order, for the reason
|
|
50
|
+
* `internal/roving-focus.js` gives about items: the two stop agreeing the first
|
|
51
|
+
* time something is rendered conditionally, and the reader's `Tab` follows the
|
|
52
|
+
* document.
|
|
53
|
+
*/
|
|
54
|
+
export function focusable(root: HTMLElement): Array<HTMLElement> {
|
|
55
|
+
return Array.from(root.querySelectorAll(FOCUS_STOPS)).filter(
|
|
56
|
+
(element: $FlowFixMe) =>
|
|
57
|
+
// All three hide a whole subtree, so all three are asked of the
|
|
58
|
+
// ancestors; see the module header for what reading them off the element
|
|
59
|
+
// alone let through.
|
|
60
|
+
element.closest("[hidden]") == null &&
|
|
61
|
+
element.closest("[inert]") == null &&
|
|
62
|
+
element.closest('[aria-hidden="true"]') == null,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|