@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/tooltip.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A tooltip: the component most likely to make a page *worse* than the plain
|
|
4
|
+
// HTML it replaced.
|
|
5
|
+
//
|
|
6
|
+
// The failure is specified rather than a matter of taste. WCAG 2.1 SC 1.4.13,
|
|
7
|
+
// *Content on Hover or Focus*, requires content shown that way to be
|
|
8
|
+
// dismissible with `Escape`, hoverable — the pointer can travel onto it — and
|
|
9
|
+
// persistent until one of those things ends it. A `title` attribute fails all
|
|
10
|
+
// three: it is on the browser's clock, it cannot be hovered, and `Escape` does
|
|
11
|
+
// nothing. A `div` that appears on `:hover` fails the first two. Those are the
|
|
12
|
+
// two things a project without this component writes.
|
|
13
|
+
//
|
|
14
|
+
// `internal/hover-intent.js` holds all three clauses, because they are one
|
|
15
|
+
// mechanism and separating them is how a component ends up with two of them.
|
|
16
|
+
//
|
|
17
|
+
// # What a tooltip is not
|
|
18
|
+
//
|
|
19
|
+
// It is **not focusable**, and nothing in it may be. A tooltip that takes focus
|
|
20
|
+
// puts a stop in the tab order the reader did not ask for and cannot leave in
|
|
21
|
+
// the direction they expect; the APG says so, and it is why anything with a
|
|
22
|
+
// link or a button in it is a `HoverCard` and not this. It carries
|
|
23
|
+
// `role="tooltip"` and describes its trigger with `aria-describedby` — and only
|
|
24
|
+
// while it is in the document, because a reference to an element that is not
|
|
25
|
+
// there makes a screen reader announce nothing at all.
|
|
26
|
+
//
|
|
27
|
+
// It has **no `aria-haspopup`**. That attribute promises an interactive popup,
|
|
28
|
+
// and a reader who is told a button has one and then finds nothing to interact
|
|
29
|
+
// with has been sent somewhere that does not exist.
|
|
30
|
+
//
|
|
31
|
+
// # The delay, and the one that must not be there
|
|
32
|
+
//
|
|
33
|
+
// A pointer resting on a trigger opens the tooltip after a delay; a pointer
|
|
34
|
+
// crossing a toolbar on its way elsewhere passes six triggers and must open
|
|
35
|
+
// none of them. **Focus opens it with no delay at all** — a reader who tabbed
|
|
36
|
+
// to a control has already said what they want, and a wait exists to filter out
|
|
37
|
+
// intent nobody expressed.
|
|
38
|
+
//
|
|
39
|
+
// `Tooltip.Provider` shares one clock between a group of tooltips, so the
|
|
40
|
+
// second icon in a toolbar answers immediately once the reader has waited out
|
|
41
|
+
// the first. A tooltip outside a provider is a complete tooltip and keeps its
|
|
42
|
+
// own delay.
|
|
43
|
+
//
|
|
44
|
+
// # Touch, which is a decision rather than an accident
|
|
45
|
+
//
|
|
46
|
+
// **This does not open on touch.** There is no hover on a touch screen, so the
|
|
47
|
+
// only gesture left is the tap — and a tooltip that opens on tap either
|
|
48
|
+
// swallows the tap the control needed or shows content the next tap dismisses
|
|
49
|
+
// before it has been read. Both are worse than nothing.
|
|
50
|
+
//
|
|
51
|
+
// What follows from that is a rule for the caller rather than for this module:
|
|
52
|
+
// a tooltip may not be the only place something is said. If the trigger is an
|
|
53
|
+
// icon button, the tooltip's text is what a reader on a phone needs *as the
|
|
54
|
+
// button's name* — so give the button an `aria-label` and let the tooltip
|
|
55
|
+
// repeat it. `pointerenter` is ignored for a touch pointer here, which is what
|
|
56
|
+
// stops the tap being eaten.
|
|
57
|
+
//
|
|
58
|
+
// # Why the pointer is watched natively
|
|
59
|
+
//
|
|
60
|
+
// `pointerenter` and `pointerleave` are the platform's way of saying the
|
|
61
|
+
// pointer arrived at *this* element; React's `onPointerEnter` is its own
|
|
62
|
+
// reconstruction of them from `pointerover` and `pointerout`. The reconstruction
|
|
63
|
+
// is faithful and it is not the same event — and the property this component
|
|
64
|
+
// has to read to know a tap from a hover, `pointerType`, belongs to the pointer
|
|
65
|
+
// event the platform sent. So the listeners go on the element, through
|
|
66
|
+
// `@uniflowed/hooks`' `useEventListener`, which is also what stops a caller's
|
|
67
|
+
// `render` function from dropping a handler by forgetting to spread it.
|
|
68
|
+
|
|
69
|
+
"use client";
|
|
70
|
+
|
|
71
|
+
import * as React from "@uniflowed/react";
|
|
72
|
+
import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
|
|
73
|
+
import { useEventListener } from "@uniflowed/hooks/dom";
|
|
74
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
75
|
+
|
|
76
|
+
import type { Align, Side } from "./internal/anchor.js";
|
|
77
|
+
import type { DelayGroup, HoverIntent } from "./internal/hover-intent.js";
|
|
78
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
79
|
+
import { composeRefs, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
80
|
+
import {
|
|
81
|
+
DEFAULT_CLOSE_DELAY,
|
|
82
|
+
DEFAULT_OPEN_DELAY,
|
|
83
|
+
DEFAULT_SKIP_DELAY,
|
|
84
|
+
useDelayGroup,
|
|
85
|
+
useDismissOnEscape,
|
|
86
|
+
useFocusableTrigger,
|
|
87
|
+
useHoverIntent,
|
|
88
|
+
} from "./internal/hover-intent.js";
|
|
89
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
90
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
91
|
+
|
|
92
|
+
export type { Align, Side } from "./internal/anchor.js";
|
|
93
|
+
|
|
94
|
+
/** What a `Tooltip.Provider` shares with the tooltips inside it. */
|
|
95
|
+
type TooltipScope = {|
|
|
96
|
+
/** The default `openDelay` for every tooltip in the group. */
|
|
97
|
+
readonly delayDuration: number,
|
|
98
|
+
readonly group: DelayGroup,
|
|
99
|
+
|};
|
|
100
|
+
|
|
101
|
+
const TooltipScopeContext: React.Context<TooltipScope | null> = createContext(null);
|
|
102
|
+
|
|
103
|
+
type TooltipState = {|
|
|
104
|
+
readonly base: string,
|
|
105
|
+
readonly open: boolean,
|
|
106
|
+
readonly setOpen: (open: boolean) => void,
|
|
107
|
+
readonly triggerRef: { current: HTMLElement | null },
|
|
108
|
+
readonly intent: HoverIntent,
|
|
109
|
+
/**
|
|
110
|
+
* How long a pointer must rest before this tooltip opens, asked at the moment
|
|
111
|
+
* it arrives rather than read from a render.
|
|
112
|
+
*
|
|
113
|
+
* A function because the answer is the group's and changes as the reader
|
|
114
|
+
* moves: the same tooltip waits the full delay when it is the first one
|
|
115
|
+
* touched and opens at once when it is the second.
|
|
116
|
+
*/
|
|
117
|
+
readonly openDelay: () => number,
|
|
118
|
+
readonly closeDelay: number,
|
|
119
|
+
/**
|
|
120
|
+
* Whether `Escape` has dismissed it while the reader is still here.
|
|
121
|
+
*
|
|
122
|
+
* A ref rather than state because nothing renders it, and because what it
|
|
123
|
+
* guards happens inside an event handler: without it, a dismissal is undone
|
|
124
|
+
* by the very next thing the pointer or focus does — which for a hover card
|
|
125
|
+
* is the focus it hands back to its own trigger, and is a component that
|
|
126
|
+
* cannot be closed. It is cleared when the reader leaves the trigger, so
|
|
127
|
+
* coming back opens it again.
|
|
128
|
+
*/
|
|
129
|
+
readonly dismissed: { current: boolean },
|
|
130
|
+
|};
|
|
131
|
+
|
|
132
|
+
const TooltipContext: React.Context<TooltipState | null> = createContext(null);
|
|
133
|
+
|
|
134
|
+
hook useTooltip(part: string): TooltipState {
|
|
135
|
+
const state = useContext(TooltipContext);
|
|
136
|
+
if (state == null) {
|
|
137
|
+
throw new Error(`${part} must be rendered inside a Tooltip.Root`);
|
|
138
|
+
}
|
|
139
|
+
return state;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One clock for a group of tooltips.
|
|
144
|
+
*
|
|
145
|
+
* `delayDuration` is the delay each tooltip inside uses unless it sets its own,
|
|
146
|
+
* so a toolbar says it once. `skipDelayDuration` is the window after one closes
|
|
147
|
+
* during which the next opens immediately — the interaction that makes a row of
|
|
148
|
+
* icon buttons readable rather than tedious.
|
|
149
|
+
*
|
|
150
|
+
* Renders no element: it is a context and a clock, and a `<div>` around a
|
|
151
|
+
* toolbar is the caller's business.
|
|
152
|
+
*/
|
|
153
|
+
export component TooltipProvider(
|
|
154
|
+
children: React.Node,
|
|
155
|
+
delayDuration?: number = DEFAULT_OPEN_DELAY,
|
|
156
|
+
skipDelayDuration?: number = DEFAULT_SKIP_DELAY,
|
|
157
|
+
) {
|
|
158
|
+
const group = useDelayGroup(skipDelayDuration);
|
|
159
|
+
const scope = useMemo(() => ({ delayDuration, group }), [delayDuration, group]);
|
|
160
|
+
|
|
161
|
+
return <TooltipScopeContext.Provider value={scope}>{children}</TooltipScopeContext.Provider>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One tooltip and its trigger.
|
|
166
|
+
*
|
|
167
|
+
* `openDelay` falls back to the enclosing `Tooltip.Provider`'s
|
|
168
|
+
* `delayDuration`, and to 700ms when there is no provider — a tooltip on its
|
|
169
|
+
* own is a complete tooltip and needs nothing around it.
|
|
170
|
+
*/
|
|
171
|
+
export component TooltipRoot(
|
|
172
|
+
children: React.Node,
|
|
173
|
+
closeDelay?: number = DEFAULT_CLOSE_DELAY,
|
|
174
|
+
defaultOpen?: boolean = false,
|
|
175
|
+
onOpenChange?: (open: boolean) => void,
|
|
176
|
+
open?: boolean,
|
|
177
|
+
openDelay?: number,
|
|
178
|
+
) {
|
|
179
|
+
const base = useId();
|
|
180
|
+
const scope = useContext(TooltipScopeContext);
|
|
181
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
182
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
183
|
+
const dismissed = useRef(false);
|
|
184
|
+
const intent = useHoverIntent(setOpen);
|
|
185
|
+
const own = openDelay ?? scope?.delayDuration ?? DEFAULT_OPEN_DELAY;
|
|
186
|
+
const group = scope?.group;
|
|
187
|
+
|
|
188
|
+
// What this tooltip last told the group, so that mounting closed says
|
|
189
|
+
// nothing — a page of six tooltips would otherwise open six skip windows
|
|
190
|
+
// before the reader had touched anything.
|
|
191
|
+
const told = useRef(false);
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (group == null || told.current === isOpen) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
told.current = isOpen;
|
|
197
|
+
if (isOpen) {
|
|
198
|
+
group.opened();
|
|
199
|
+
} else {
|
|
200
|
+
group.closed();
|
|
201
|
+
}
|
|
202
|
+
}, [group, isOpen]);
|
|
203
|
+
|
|
204
|
+
// A tooltip taken away while it was showing has to say so, or the group is
|
|
205
|
+
// left believing one is open and answers every later hover instantly.
|
|
206
|
+
useEffect(
|
|
207
|
+
() => () => {
|
|
208
|
+
if (told.current) {
|
|
209
|
+
told.current = false;
|
|
210
|
+
group?.closed();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
[group],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const state = useMemo(
|
|
217
|
+
() => ({
|
|
218
|
+
base,
|
|
219
|
+
closeDelay,
|
|
220
|
+
dismissed,
|
|
221
|
+
intent,
|
|
222
|
+
open: isOpen,
|
|
223
|
+
openDelay: () => group?.delayFor(own) ?? own,
|
|
224
|
+
setOpen,
|
|
225
|
+
triggerRef,
|
|
226
|
+
}),
|
|
227
|
+
[base, closeDelay, group, intent, isOpen, own, setOpen],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return <TooltipContext.Provider value={state}>{children}</TooltipContext.Provider>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* What the tooltip describes.
|
|
235
|
+
*
|
|
236
|
+
* A `<button>` by default, and whatever the caller renders when they pass
|
|
237
|
+
* `render` — a link, a menu item, an icon button of their own. Either way it
|
|
238
|
+
* has to be something the keyboard can reach, and `useFocusableTrigger` refuses
|
|
239
|
+
* anything else: a tooltip on a `<span>` is a tooltip only a mouse can find,
|
|
240
|
+
* and it looks perfect in the markup.
|
|
241
|
+
*
|
|
242
|
+
* Every handler is attached to the element rather than passed as a prop, so a
|
|
243
|
+
* `render` function that forgets to spread something still gets a working
|
|
244
|
+
* tooltip; see the module header.
|
|
245
|
+
*/
|
|
246
|
+
export component TooltipTrigger(
|
|
247
|
+
children?: React.Node,
|
|
248
|
+
render?: (props: Rest) => React.Node,
|
|
249
|
+
...rest: Rest
|
|
250
|
+
) {
|
|
251
|
+
const tooltip = useTooltip("Tooltip.Trigger");
|
|
252
|
+
const { closeDelay, dismissed, intent, openDelay, setOpen, triggerRef } = tooltip;
|
|
253
|
+
useFocusableTrigger(triggerRef, "Tooltip.Trigger");
|
|
254
|
+
|
|
255
|
+
// Whether the pointer put focus here. A click focuses the button, and
|
|
256
|
+
// reopening the tooltip the click just dismissed would put it back over the
|
|
257
|
+
// thing the reader pressed — so a focus that arrived with a press opens
|
|
258
|
+
// nothing, and the next one, which is the keyboard's, does.
|
|
259
|
+
const pressed = useRef(false);
|
|
260
|
+
|
|
261
|
+
useEventListener(triggerRef, "pointerenter", (event: $FlowFixMe) => {
|
|
262
|
+
// A tap is not a hover. See the module header: opening here is what eats
|
|
263
|
+
// the tap the control was there to receive.
|
|
264
|
+
if (event.pointerType === "touch" || dismissed.current) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
intent.openAfter(openDelay());
|
|
268
|
+
});
|
|
269
|
+
useEventListener(triggerRef, "pointerleave", () => {
|
|
270
|
+
// Leaving is what makes a dismissal stop applying: coming back is a fresh
|
|
271
|
+
// gesture and deserves a fresh answer.
|
|
272
|
+
dismissed.current = false;
|
|
273
|
+
intent.closeAfter(closeDelay);
|
|
274
|
+
});
|
|
275
|
+
useEventListener(triggerRef, "pointerdown", () => {
|
|
276
|
+
pressed.current = true;
|
|
277
|
+
intent.cancel();
|
|
278
|
+
setOpen(false);
|
|
279
|
+
});
|
|
280
|
+
useEventListener(triggerRef, "focusin", () => {
|
|
281
|
+
if (pressed.current) {
|
|
282
|
+
pressed.current = false;
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (dismissed.current) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// No delay: the reader has already said what they want by arriving here.
|
|
289
|
+
intent.openAfter(0);
|
|
290
|
+
});
|
|
291
|
+
useEventListener(triggerRef, "focusout", () => {
|
|
292
|
+
pressed.current = false;
|
|
293
|
+
dismissed.current = false;
|
|
294
|
+
intent.closeAfter(0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Annotated because this one is not written inside a `ref={...}`, and there
|
|
298
|
+
// is nothing else here for Flow to infer the element's type from.
|
|
299
|
+
const attach = composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
300
|
+
triggerRef.current = element;
|
|
301
|
+
});
|
|
302
|
+
// Only while it is there. `aria-describedby` pointing at an element that has
|
|
303
|
+
// been removed is the dangling reference this package keeps coming back to.
|
|
304
|
+
const ours = {
|
|
305
|
+
"aria-describedby": tooltip.open ? `${tooltip.base}-body` : undefined,
|
|
306
|
+
ref: attach,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (render != null) {
|
|
310
|
+
return render(withProps(withoutComposed(rest, ["ref"]), ours));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<button
|
|
315
|
+
{...withoutComposed(rest, ["ref"])}
|
|
316
|
+
aria-describedby={ours["aria-describedby"]}
|
|
317
|
+
ref={attach}
|
|
318
|
+
type="button"
|
|
319
|
+
>
|
|
320
|
+
{children}
|
|
321
|
+
</button>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The tooltip itself.
|
|
327
|
+
*
|
|
328
|
+
* Hoverable, which is the clause every hand-written tooltip fails: the pointer
|
|
329
|
+
* arriving here calls off the close the trigger scheduled when the pointer
|
|
330
|
+
* left it, so the trip across the gap does not take the content away. It never
|
|
331
|
+
* takes focus, holds no tab stop, and answers `Escape` from wherever focus
|
|
332
|
+
* happens to be.
|
|
333
|
+
*/
|
|
334
|
+
export component TooltipBody(
|
|
335
|
+
children: React.Node,
|
|
336
|
+
align?: Align = "center",
|
|
337
|
+
alignOffset?: number = 0,
|
|
338
|
+
avoidCollisions?: boolean = true,
|
|
339
|
+
collisionPadding?: number = 0,
|
|
340
|
+
side?: Side = "top",
|
|
341
|
+
sideOffset?: number = 0,
|
|
342
|
+
...rest: Rest
|
|
343
|
+
) {
|
|
344
|
+
const tooltip = useTooltip("Tooltip.Body");
|
|
345
|
+
const { closeDelay, intent, open, triggerRef } = tooltip;
|
|
346
|
+
const bodyRef = useRef<HTMLElement | null>(null);
|
|
347
|
+
const close = useStableCallback(() => {
|
|
348
|
+
// `Escape` dismisses it *and* keeps it dismissed while the reader is still
|
|
349
|
+
// on the trigger. Without the flag the pointer that is still resting there
|
|
350
|
+
// — or, for a hover card, the focus it hands back — reopens it at once,
|
|
351
|
+
// and the key does nothing a reader can see.
|
|
352
|
+
tooltip.dismissed.current = true;
|
|
353
|
+
intent.cancel();
|
|
354
|
+
tooltip.setOpen(false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const anchored = useAnchor({
|
|
358
|
+
align,
|
|
359
|
+
alignOffset,
|
|
360
|
+
anchorRef: triggerRef,
|
|
361
|
+
avoidCollisions,
|
|
362
|
+
collisionPadding,
|
|
363
|
+
open,
|
|
364
|
+
overlayRef: bodyRef,
|
|
365
|
+
side,
|
|
366
|
+
sideOffset,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
useDismissOnEscape(open, bodyRef, close);
|
|
370
|
+
|
|
371
|
+
// Keyed on `open` rather than written with `useEventListener`, and the
|
|
372
|
+
// difference is load-bearing: that hook reads its target once, when it
|
|
373
|
+
// attaches, and this element does not exist until the tooltip opens. It is
|
|
374
|
+
// the same trap `select.js` documents about its outside-press listener.
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
const body = bodyRef.current;
|
|
377
|
+
if (!open || body == null) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const stay = () => intent.cancel();
|
|
381
|
+
const go = () => intent.closeAfter(closeDelay);
|
|
382
|
+
body.addEventListener("pointerenter", stay);
|
|
383
|
+
body.addEventListener("pointerleave", go);
|
|
384
|
+
return () => {
|
|
385
|
+
body.removeEventListener("pointerenter", stay);
|
|
386
|
+
body.removeEventListener("pointerleave", go);
|
|
387
|
+
};
|
|
388
|
+
}, [open, intent, closeDelay]);
|
|
389
|
+
|
|
390
|
+
if (!open) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div
|
|
396
|
+
{...withoutComposed(rest, ["ref"])}
|
|
397
|
+
data-align={anchored.align}
|
|
398
|
+
data-side={anchored.side}
|
|
399
|
+
data-state="open"
|
|
400
|
+
id={`${tooltip.base}-body`}
|
|
401
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
402
|
+
bodyRef.current = element;
|
|
403
|
+
})}
|
|
404
|
+
role="tooltip"
|
|
405
|
+
// No `tabIndex`. A tooltip the keyboard can land in is a stop the reader
|
|
406
|
+
// did not ask for and cannot leave the way they expect.
|
|
407
|
+
>
|
|
408
|
+
{children}
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|