@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/menu.js
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A menu, which is the widget whose keyboard map people know by feel and cannot
|
|
4
|
+
// name.
|
|
5
|
+
//
|
|
6
|
+
// Every native menu on every platform has behaved the same way for thirty
|
|
7
|
+
// years, so a reader arrives already knowing what the keys do — and notices
|
|
8
|
+
// immediately when one of them does nothing:
|
|
9
|
+
//
|
|
10
|
+
// * `ArrowDown` / `ArrowUp` move between items and wrap at the ends.
|
|
11
|
+
// * `Home` / `End` go to the first and last item.
|
|
12
|
+
// * Typing letters jumps to an item by prefix, and typing the same letter
|
|
13
|
+
// again cycles between the items that start with it. A thirty-item menu
|
|
14
|
+
// without typeahead is thirty arrow presses.
|
|
15
|
+
// * `Escape` closes *this* menu — the submenu if one is open, not the whole
|
|
16
|
+
// tree — and gives focus back to what opened it.
|
|
17
|
+
// * `ArrowRight` opens a submenu and lands on its first item; `ArrowLeft`
|
|
18
|
+
// closes it and comes back to the item that opened it — and the two swap in
|
|
19
|
+
// a right-to-left page, because a submenu opens onto the *inline end*.
|
|
20
|
+
// * `Tab` closes the menu and carries on through the page, rather than
|
|
21
|
+
// walking the reader through thirty items they have already dismissed.
|
|
22
|
+
//
|
|
23
|
+
// # Focus moves; `aria-activedescendant` does not appear here
|
|
24
|
+
//
|
|
25
|
+
// A menu moves *real* DOM focus onto its items. That is what WAI-ARIA
|
|
26
|
+
// prescribes for this pattern, and it is why the items are buttons: activation,
|
|
27
|
+
// disabled semantics and the focus ring are the browser's rather than this
|
|
28
|
+
// component's. `aria-activedescendant` — a "virtual" focus that stays on the
|
|
29
|
+
// container — belongs to the pattern where focus cannot leave a text field,
|
|
30
|
+
// which is the combobox, and `combobox.js` uses it there.
|
|
31
|
+
//
|
|
32
|
+
// # Why hovering does not open a submenu
|
|
33
|
+
//
|
|
34
|
+
// It does nothing here on purpose. Opening on hover requires an intent
|
|
35
|
+
// heuristic — the "safe triangle" that lets the pointer travel diagonally
|
|
36
|
+
// across a sibling item to reach the submenu without it snapping shut — and a
|
|
37
|
+
// naive `onPointerEnter` that opens immediately is *worse* than no hover at
|
|
38
|
+
// all: it opens menus the reader was only passing over and closes the one they
|
|
39
|
+
// were aiming at. Keyboard and click open a submenu; a deliberate hover
|
|
40
|
+
// implementation is tracked work, not a line to be added carelessly.
|
|
41
|
+
//
|
|
42
|
+
// # Items are found in the document, not in a registry
|
|
43
|
+
//
|
|
44
|
+
// `internal/roving-focus.js` explains why. The short version is that mount
|
|
45
|
+
// order stops being document order the first time an item is conditional, and
|
|
46
|
+
// a submenu's items live *inside* its parent menu's element.
|
|
47
|
+
|
|
48
|
+
"use client";
|
|
49
|
+
|
|
50
|
+
import * as React from "@uniflowed/react";
|
|
51
|
+
import {
|
|
52
|
+
createContext,
|
|
53
|
+
useContext,
|
|
54
|
+
useEffect,
|
|
55
|
+
useId,
|
|
56
|
+
useMemo,
|
|
57
|
+
useRef,
|
|
58
|
+
useState,
|
|
59
|
+
} from "@uniflowed/react";
|
|
60
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
61
|
+
|
|
62
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
63
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
64
|
+
import {
|
|
65
|
+
directionOf,
|
|
66
|
+
indexOfActive,
|
|
67
|
+
isTypeaheadKey,
|
|
68
|
+
itemsOf,
|
|
69
|
+
movementFor,
|
|
70
|
+
moveTo,
|
|
71
|
+
useTypeahead,
|
|
72
|
+
} from "./internal/roving-focus.js";
|
|
73
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
74
|
+
import type { Direction } from "./internal/roving-focus.js";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Anything that plays the part of a menu item, including the two checkable
|
|
78
|
+
* kinds a caller may write themselves. The keyboard has to move between all of
|
|
79
|
+
* them, so the selector names all of them rather than only what this package
|
|
80
|
+
* ships.
|
|
81
|
+
*/
|
|
82
|
+
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
|
|
83
|
+
|
|
84
|
+
/** What owns an item: the nearest menu, so a submenu keeps its own. */
|
|
85
|
+
const MENU_SELECTOR = '[role="menu"]';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Which arrow key opens a submenu, and which closes it.
|
|
89
|
+
*
|
|
90
|
+
* The WAI-ARIA menu pattern puts a submenu on the *inline end*, so it opens to
|
|
91
|
+
* the right of a left-to-right menu and to the left of a right-to-left one, and
|
|
92
|
+
* the key that opens it is the one pointing at it. Written out as
|
|
93
|
+
* `ArrowRight` to open and `ArrowLeft` to close, an RTL reader pressed the key
|
|
94
|
+
* aimed at the submenu and closed the menu they were standing in — which is
|
|
95
|
+
* worse than nothing happening, because it loses their place.
|
|
96
|
+
*/
|
|
97
|
+
function submenuKeys(direction: Direction): {| readonly open: string, readonly close: string |} {
|
|
98
|
+
return direction === "rtl"
|
|
99
|
+
? { open: "ArrowLeft", close: "ArrowRight" }
|
|
100
|
+
: { open: "ArrowRight", close: "ArrowLeft" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type MenuState = {|
|
|
104
|
+
readonly base: string,
|
|
105
|
+
readonly open: boolean,
|
|
106
|
+
readonly setOpen: (open: boolean) => void,
|
|
107
|
+
/** What opened this menu, and what focus goes back to when it closes. */
|
|
108
|
+
readonly triggerRef: { current: HTMLElement | null },
|
|
109
|
+
/**
|
|
110
|
+
* Which end the menu should open onto, written by whatever opened it.
|
|
111
|
+
*
|
|
112
|
+
* A ref rather than state because it is an instruction for the next commit,
|
|
113
|
+
* not a value anything renders: `ArrowUp` on a closed menu opens it *and*
|
|
114
|
+
* lands on the last item, and re-rendering the trigger to say so would be a
|
|
115
|
+
* render whose only purpose is to carry a message to an effect.
|
|
116
|
+
*/
|
|
117
|
+
readonly pendingFocus: { current: "first" | "last" | null },
|
|
118
|
+
/** The menu this one hangs off, or null for the outermost. */
|
|
119
|
+
readonly parent: MenuState | null,
|
|
120
|
+
/**
|
|
121
|
+
* Whether a trigger is rendered, so the body only names one that exists.
|
|
122
|
+
*
|
|
123
|
+
* A menu opened by `defaultOpen` in a page that never renders a trigger is a
|
|
124
|
+
* real arrangement, and an `aria-labelledby` pointing at the id that trigger
|
|
125
|
+
* *would* have had makes a screen reader announce nothing at all.
|
|
126
|
+
*/
|
|
127
|
+
readonly triggered: boolean,
|
|
128
|
+
readonly registerTrigger: (present: boolean) => void,
|
|
129
|
+
|};
|
|
130
|
+
|
|
131
|
+
const MenuContext: React.Context<MenuState | null> = createContext(null);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The roving tab stop of one open menu.
|
|
135
|
+
*
|
|
136
|
+
* Provided by `Menu.Body` rather than by the root, because a submenu is a
|
|
137
|
+
* second list with a tab stop of its own: nesting the provider is what stops
|
|
138
|
+
* the parent menu and the submenu from fighting over which item is `tabindex=0`.
|
|
139
|
+
*/
|
|
140
|
+
type MenuListState = {|
|
|
141
|
+
readonly activeId: string | null,
|
|
142
|
+
readonly setActiveId: (id: string | null) => void,
|
|
143
|
+
|};
|
|
144
|
+
|
|
145
|
+
const MenuListContext: React.Context<MenuListState | null> = createContext(null);
|
|
146
|
+
|
|
147
|
+
/** The id of a group's label, so `Menu.Group` only claims one that exists. */
|
|
148
|
+
type MenuGroupState = {|
|
|
149
|
+
readonly labelId: string,
|
|
150
|
+
readonly registerLabel: (present: boolean) => void,
|
|
151
|
+
|};
|
|
152
|
+
|
|
153
|
+
const MenuGroupContext: React.Context<MenuGroupState | null> = createContext(null);
|
|
154
|
+
|
|
155
|
+
hook useMenu(part: string): MenuState {
|
|
156
|
+
const state = useContext(MenuContext);
|
|
157
|
+
if (state == null) {
|
|
158
|
+
throw new Error(`${part} must be rendered inside a Menu.Root`);
|
|
159
|
+
}
|
|
160
|
+
return state;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Tell the menu that a trigger for it is in the document.
|
|
165
|
+
*
|
|
166
|
+
* `Menu.Body` names its trigger with `aria-labelledby`, and it may only do that
|
|
167
|
+
* while there is one to name — a menu opened by `defaultOpen` in a page with no
|
|
168
|
+
* trigger would otherwise point at an id nothing has, and a screen reader given
|
|
169
|
+
* a dangling `aria-labelledby` announces nothing at all rather than falling back
|
|
170
|
+
* to the element's own content.
|
|
171
|
+
*/
|
|
172
|
+
hook useTriggerRegistration(menu: MenuState): void {
|
|
173
|
+
const register = menu.registerTrigger;
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
register(true);
|
|
176
|
+
return () => register(false);
|
|
177
|
+
}, [register]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Every menu from `menu` outwards, innermost first. */
|
|
181
|
+
function ancestry(menu: MenuState): Array<MenuState> {
|
|
182
|
+
const chain = [];
|
|
183
|
+
let at: MenuState | null = menu;
|
|
184
|
+
while (at != null) {
|
|
185
|
+
chain.push(at);
|
|
186
|
+
at = at.parent;
|
|
187
|
+
}
|
|
188
|
+
return chain;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Close this menu and every menu it hangs off.
|
|
193
|
+
*
|
|
194
|
+
* Choosing an item in a submenu dismisses the whole thing — leaving the parent
|
|
195
|
+
* menu open after a command has run is a state no native menu has ever been in,
|
|
196
|
+
* and it leaves the reader looking at a menu whose action already happened.
|
|
197
|
+
*/
|
|
198
|
+
function closeTree(menu: MenuState): void {
|
|
199
|
+
for (const each of ancestry(menu)) {
|
|
200
|
+
each.setOpen(false);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A menu and its trigger.
|
|
206
|
+
*
|
|
207
|
+
* Renders no element of its own: a menu's trigger and its body are siblings in
|
|
208
|
+
* whatever layout the caller wrote, and a wrapper would put a `<div>` between
|
|
209
|
+
* them that the caller then has to style around.
|
|
210
|
+
*/
|
|
211
|
+
export component MenuRoot(
|
|
212
|
+
children: React.Node,
|
|
213
|
+
defaultOpen?: boolean = false,
|
|
214
|
+
open?: boolean,
|
|
215
|
+
onOpenChange?: (open: boolean) => void,
|
|
216
|
+
) {
|
|
217
|
+
return (
|
|
218
|
+
<MenuLevel defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open} parent={null}>
|
|
219
|
+
{children}
|
|
220
|
+
</MenuLevel>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A submenu: a menu whose trigger is an item of the menu around it.
|
|
226
|
+
*
|
|
227
|
+
* It is the same component as a root menu with one difference — it knows its
|
|
228
|
+
* parent — and that difference is what `ArrowLeft`, `Escape` and "choosing an
|
|
229
|
+
* item closes everything" are all defined in terms of.
|
|
230
|
+
*/
|
|
231
|
+
export component MenuSub(
|
|
232
|
+
children: React.Node,
|
|
233
|
+
defaultOpen?: boolean = false,
|
|
234
|
+
open?: boolean,
|
|
235
|
+
onOpenChange?: (open: boolean) => void,
|
|
236
|
+
) {
|
|
237
|
+
const parent = useContext(MenuContext);
|
|
238
|
+
if (parent == null) {
|
|
239
|
+
throw new Error("Menu.Sub must be rendered inside a Menu.Root");
|
|
240
|
+
}
|
|
241
|
+
return (
|
|
242
|
+
<MenuLevel defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open} parent={parent}>
|
|
243
|
+
{children}
|
|
244
|
+
</MenuLevel>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** One level of the menu tree. Shared by `Menu.Root` and `Menu.Sub`. */
|
|
249
|
+
component MenuLevel(
|
|
250
|
+
children: React.Node,
|
|
251
|
+
parent: MenuState | null,
|
|
252
|
+
defaultOpen: boolean,
|
|
253
|
+
open?: boolean,
|
|
254
|
+
onOpenChange?: (open: boolean) => void,
|
|
255
|
+
) {
|
|
256
|
+
const base = useId();
|
|
257
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
258
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
259
|
+
const pendingFocus = useRef<"first" | "last" | null>(null);
|
|
260
|
+
const [triggered, setTriggered] = useState(false);
|
|
261
|
+
|
|
262
|
+
const state = useMemo(
|
|
263
|
+
() => ({
|
|
264
|
+
base,
|
|
265
|
+
open: isOpen,
|
|
266
|
+
setOpen,
|
|
267
|
+
triggerRef,
|
|
268
|
+
pendingFocus,
|
|
269
|
+
parent,
|
|
270
|
+
triggered,
|
|
271
|
+
registerTrigger: setTriggered,
|
|
272
|
+
}),
|
|
273
|
+
[base, isOpen, setOpen, parent, triggered],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
return <MenuContext.Provider value={state}>{children}</MenuContext.Provider>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The button that opens the menu. */
|
|
280
|
+
export component MenuTrigger(children: React.Node, ...rest: Rest) {
|
|
281
|
+
const menu = useMenu("Menu.Trigger");
|
|
282
|
+
const passed = withoutComposed(rest, ["onClick", "onKeyDown", "ref"]);
|
|
283
|
+
useTriggerRegistration(menu);
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<button
|
|
287
|
+
{...passed}
|
|
288
|
+
// Named only while the menu is in the document, so a reader is never told
|
|
289
|
+
// to go somewhere that is not there.
|
|
290
|
+
aria-controls={menu.open ? `${menu.base}-body` : undefined}
|
|
291
|
+
aria-expanded={menu.open ? "true" : "false"}
|
|
292
|
+
aria-haspopup="menu"
|
|
293
|
+
id={`${menu.base}-trigger`}
|
|
294
|
+
onClick={composeHandlers(rest.onClick, () => menu.setOpen(!menu.open))}
|
|
295
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
296
|
+
// `ArrowUp` opening onto the *last* item is the behaviour that makes a
|
|
297
|
+
// long menu usable: the last entry is usually the destructive one, and
|
|
298
|
+
// reaching it should not mean arrowing past everything else.
|
|
299
|
+
const end = match (event.key) {
|
|
300
|
+
"ArrowDown" => "first",
|
|
301
|
+
"ArrowUp" => "last",
|
|
302
|
+
_ => null,
|
|
303
|
+
};
|
|
304
|
+
if (end == null) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
event.preventDefault();
|
|
308
|
+
menu.pendingFocus.current = end;
|
|
309
|
+
menu.setOpen(true);
|
|
310
|
+
})}
|
|
311
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
312
|
+
menu.triggerRef.current = element;
|
|
313
|
+
})}
|
|
314
|
+
type="button"
|
|
315
|
+
>
|
|
316
|
+
{children}
|
|
317
|
+
</button>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The menu itself: the roving tab stop, the arrow keys, typeahead and Escape.
|
|
323
|
+
*
|
|
324
|
+
* The keys are handled here rather than on each item because every one of them
|
|
325
|
+
* is a question about the *set* — "the next item", "the item starting with r" —
|
|
326
|
+
* and only the container can answer it. Items still get their own `Enter` and
|
|
327
|
+
* `Space` from being buttons.
|
|
328
|
+
*/
|
|
329
|
+
export component MenuBody(
|
|
330
|
+
children: renders* (MenuItem | MenuSeparator | MenuGroup | MenuSub),
|
|
331
|
+
...rest: Rest
|
|
332
|
+
) {
|
|
333
|
+
const menu = useMenu("Menu.Body");
|
|
334
|
+
const bodyRef = useRef<HTMLElement | null>(null);
|
|
335
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
336
|
+
const typeahead = useTypeahead();
|
|
337
|
+
|
|
338
|
+
// Pulled out because they are stable for the life of the menu, which is what
|
|
339
|
+
// lets the effect below depend on `open` alone. Keyed on the context object
|
|
340
|
+
// it re-ran on every parent render and re-took focus each time, dragging the
|
|
341
|
+
// reader back to the first item while they were arrowing.
|
|
342
|
+
const triggerRef = menu.triggerRef;
|
|
343
|
+
const pendingFocus = menu.pendingFocus;
|
|
344
|
+
const isRoot = menu.parent == null;
|
|
345
|
+
const closeAll = useStableCallback(() => closeTree(menu));
|
|
346
|
+
// Set when the menu was dismissed by a press somewhere else, so the cleanup
|
|
347
|
+
// knows not to drag focus back to the trigger the reader just left.
|
|
348
|
+
const dismissed = useRef(false);
|
|
349
|
+
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
const body = bodyRef.current;
|
|
352
|
+
if (!menu.open || body == null) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const document = body.ownerDocument;
|
|
356
|
+
const trigger = triggerRef.current;
|
|
357
|
+
|
|
358
|
+
const wanted = pendingFocus.current;
|
|
359
|
+
pendingFocus.current = null;
|
|
360
|
+
const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
|
|
361
|
+
const landing = moveTo(items, -1, wanted === "last" ? "last" : "first", false);
|
|
362
|
+
// The menu itself when it holds nothing focusable, so focus is inside it
|
|
363
|
+
// either way and Escape still reaches this component's handler.
|
|
364
|
+
(landing ?? body).focus();
|
|
365
|
+
if (landing != null) {
|
|
366
|
+
setActiveId(landing.id);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const onOutsidePress = (event: Event) => {
|
|
370
|
+
const target: $FlowFixMe = event.target;
|
|
371
|
+
if (target == null || body.contains(target)) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// The trigger is outside the menu and is not "outside" for this purpose:
|
|
375
|
+
// closing here and letting the trigger's own click reopen made a press on
|
|
376
|
+
// the trigger a no-op that flickered.
|
|
377
|
+
if (trigger != null && trigger.contains(target)) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
dismissed.current = true;
|
|
381
|
+
closeAll();
|
|
382
|
+
};
|
|
383
|
+
// Only the outermost menu listens. A submenu closes with the tree, and two
|
|
384
|
+
// listeners would each answer the same press.
|
|
385
|
+
if (isRoot) {
|
|
386
|
+
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return () => {
|
|
390
|
+
if (isRoot) {
|
|
391
|
+
document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
392
|
+
}
|
|
393
|
+
if (dismissed.current) {
|
|
394
|
+
dismissed.current = false;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// Only when focus would otherwise be lost. Choosing an item in a submenu
|
|
398
|
+
// closes three menus at once, and each one restoring focus to its own
|
|
399
|
+
// trigger would leave it on a button that is itself being removed.
|
|
400
|
+
const active = document.activeElement;
|
|
401
|
+
if (active == null || active === document.body || body.contains(active)) {
|
|
402
|
+
trigger?.focus?.();
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
}, [menu.open, isRoot, triggerRef, pendingFocus, closeAll]);
|
|
406
|
+
|
|
407
|
+
const list = useMemo(() => ({ activeId, setActiveId }), [activeId]);
|
|
408
|
+
|
|
409
|
+
if (!menu.open) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<MenuListContext.Provider value={list}>
|
|
417
|
+
<div
|
|
418
|
+
{...passed}
|
|
419
|
+
aria-labelledby={menu.triggered ? `${menu.base}-trigger` : undefined}
|
|
420
|
+
aria-orientation="vertical"
|
|
421
|
+
id={`${menu.base}-body`}
|
|
422
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
423
|
+
const body: $FlowFixMe = event.currentTarget;
|
|
424
|
+
const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
|
|
425
|
+
const at = indexOfActive(items, body.ownerDocument?.activeElement);
|
|
426
|
+
|
|
427
|
+
if (event.key === "Escape") {
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
// This menu, not the one behind it and not the dialog around it.
|
|
430
|
+
// A submenu is a DOM descendant of its parent menu, so without this
|
|
431
|
+
// one Escape closed the whole tree at once.
|
|
432
|
+
event.stopPropagation();
|
|
433
|
+
menu.setOpen(false);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (event.key === "Tab") {
|
|
438
|
+
// Not prevented: the browser should carry on to the next control,
|
|
439
|
+
// which is what makes Tab a way *past* a menu rather than a way
|
|
440
|
+
// through its thirty items.
|
|
441
|
+
event.stopPropagation();
|
|
442
|
+
closeAll();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Asked once, here, and used for both questions below: which key
|
|
447
|
+
// closes this submenu, and — for a menu a caller has laid out
|
|
448
|
+
// horizontally one day — which way the arrows run.
|
|
449
|
+
const direction = directionOf(body);
|
|
450
|
+
|
|
451
|
+
if (!isRoot && event.key === submenuKeys(direction).close) {
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
event.stopPropagation();
|
|
454
|
+
menu.setOpen(false);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const movement = movementFor(event.key, "vertical", direction);
|
|
459
|
+
if (movement != null) {
|
|
460
|
+
// Before moving, or the arrow also scrolls the page under the item
|
|
461
|
+
// that just took focus.
|
|
462
|
+
event.preventDefault();
|
|
463
|
+
event.stopPropagation();
|
|
464
|
+
const next = moveTo(items, at, movement, true);
|
|
465
|
+
if (next != null) {
|
|
466
|
+
next.focus();
|
|
467
|
+
setActiveId(next.id);
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (isTypeaheadKey(event)) {
|
|
473
|
+
const next = typeahead(items, at, event.key);
|
|
474
|
+
if (next != null) {
|
|
475
|
+
event.preventDefault();
|
|
476
|
+
event.stopPropagation();
|
|
477
|
+
next.focus();
|
|
478
|
+
setActiveId(next.id);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
})}
|
|
482
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
483
|
+
bodyRef.current = element;
|
|
484
|
+
})}
|
|
485
|
+
role="menu"
|
|
486
|
+
// So the menu can hold focus itself when it is empty, and so a press on
|
|
487
|
+
// its padding does not send focus to `<body>`.
|
|
488
|
+
tabIndex={-1}
|
|
489
|
+
>
|
|
490
|
+
{children}
|
|
491
|
+
</div>
|
|
492
|
+
</MenuListContext.Provider>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* One command in the menu.
|
|
498
|
+
*
|
|
499
|
+
* A disabled item is `aria-disabled` rather than `disabled`, so it stays in the
|
|
500
|
+
* accessibility tree: a reader is told "Delete, menu item, dimmed" and learns
|
|
501
|
+
* that the command exists and is unavailable, where a native `disabled` leaves
|
|
502
|
+
* a silent gap they cannot ask about. The arrow keys and typeahead step over it
|
|
503
|
+
* either way.
|
|
504
|
+
*/
|
|
505
|
+
export component MenuItem(
|
|
506
|
+
children: React.Node,
|
|
507
|
+
disabled?: boolean = false,
|
|
508
|
+
onSelect?: () => mixed,
|
|
509
|
+
...rest: Rest
|
|
510
|
+
) {
|
|
511
|
+
const menu = useMenu("Menu.Item");
|
|
512
|
+
const list = useContext(MenuListContext);
|
|
513
|
+
const id = useId();
|
|
514
|
+
const passed = withoutComposed(rest, ["onClick", "onFocus"]);
|
|
515
|
+
const setActiveId = list?.setActiveId;
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
<button
|
|
519
|
+
{...passed}
|
|
520
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
521
|
+
id={id}
|
|
522
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
523
|
+
if (disabled) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
onSelect?.();
|
|
527
|
+
closeTree(menu);
|
|
528
|
+
})}
|
|
529
|
+
// The roving tab stop follows real focus rather than leading it, so a
|
|
530
|
+
// pointer that moves focus and a key that moves focus agree without the
|
|
531
|
+
// two of them having to be kept in step by hand.
|
|
532
|
+
onFocus={composeHandlers(rest.onFocus, () => setActiveId?.(id))}
|
|
533
|
+
role="menuitem"
|
|
534
|
+
tabIndex={list?.activeId === id ? 0 : -1}
|
|
535
|
+
type="button"
|
|
536
|
+
>
|
|
537
|
+
{children}
|
|
538
|
+
</button>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The item that opens a submenu.
|
|
544
|
+
*
|
|
545
|
+
* It is a menu item of the *outer* menu and the trigger of the inner one, which
|
|
546
|
+
* is why it reads the list context of the menu around it and the menu context
|
|
547
|
+
* of the one below it.
|
|
548
|
+
*/
|
|
549
|
+
export component MenuSubTrigger(children: React.Node, ...rest: Rest) {
|
|
550
|
+
const menu = useMenu("Menu.SubTrigger");
|
|
551
|
+
const list = useContext(MenuListContext);
|
|
552
|
+
const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]);
|
|
553
|
+
const setActiveId = list?.setActiveId;
|
|
554
|
+
useTriggerRegistration(menu);
|
|
555
|
+
// The submenu's own trigger id, not a fresh one: the submenu names itself
|
|
556
|
+
// after it, and two ids for one element is how that link went stale.
|
|
557
|
+
const id = `${menu.base}-trigger`;
|
|
558
|
+
|
|
559
|
+
const open = () => {
|
|
560
|
+
menu.pendingFocus.current = "first";
|
|
561
|
+
menu.setOpen(true);
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
return (
|
|
565
|
+
<button
|
|
566
|
+
{...passed}
|
|
567
|
+
aria-controls={menu.open ? `${menu.base}-body` : undefined}
|
|
568
|
+
aria-expanded={menu.open ? "true" : "false"}
|
|
569
|
+
aria-haspopup="menu"
|
|
570
|
+
id={id}
|
|
571
|
+
onClick={composeHandlers(rest.onClick, open)}
|
|
572
|
+
onFocus={composeHandlers(rest.onFocus, () => setActiveId?.(id))}
|
|
573
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
574
|
+
const trigger: $FlowFixMe = event.currentTarget;
|
|
575
|
+
if (event.key !== submenuKeys(directionOf(trigger)).open) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
event.preventDefault();
|
|
579
|
+
// The parent menu's own `ArrowRight` does nothing, but a menu three
|
|
580
|
+
// levels deep would otherwise see this key at every level.
|
|
581
|
+
event.stopPropagation();
|
|
582
|
+
open();
|
|
583
|
+
})}
|
|
584
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
585
|
+
menu.triggerRef.current = element;
|
|
586
|
+
})}
|
|
587
|
+
role="menuitem"
|
|
588
|
+
tabIndex={list?.activeId === id ? 0 : -1}
|
|
589
|
+
type="button"
|
|
590
|
+
>
|
|
591
|
+
{children}
|
|
592
|
+
</button>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* A rule between groups of items.
|
|
598
|
+
*
|
|
599
|
+
* `role="separator"` rather than an `<hr>` with a border, because a reader
|
|
600
|
+
* moving through the menu is told the group changed. It is not focusable and
|
|
601
|
+
* the arrow keys pass straight over it.
|
|
602
|
+
*/
|
|
603
|
+
export component MenuSeparator(...rest: Rest) {
|
|
604
|
+
return <div {...rest} aria-orientation="horizontal" role="separator" />;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* A named group of items.
|
|
609
|
+
*
|
|
610
|
+
* The name has to reach the group through `aria-labelledby`, and only when a
|
|
611
|
+
* `Menu.Label` is actually rendered — an `aria-labelledby` pointing at an id
|
|
612
|
+
* that is not in the document makes a screen reader announce *nothing*, which
|
|
613
|
+
* is worse than an unnamed group.
|
|
614
|
+
*/
|
|
615
|
+
export component MenuGroup(children: React.Node, ...rest: Rest) {
|
|
616
|
+
const base = useId();
|
|
617
|
+
const [labelled, setLabelled] = useState(false);
|
|
618
|
+
|
|
619
|
+
const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<MenuGroupContext.Provider value={group}>
|
|
623
|
+
<div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
|
|
624
|
+
{children}
|
|
625
|
+
</div>
|
|
626
|
+
</MenuGroupContext.Provider>
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The heading of a `Menu.Group`.
|
|
632
|
+
*
|
|
633
|
+
* `role="presentation"` because the group already carries the name: leaving it
|
|
634
|
+
* as ordinary content would have a reader hear the heading once as the group's
|
|
635
|
+
* name and again as a stray line of text between the items.
|
|
636
|
+
*/
|
|
637
|
+
export component MenuLabel(children: React.Node, ...rest: Rest) {
|
|
638
|
+
const group = useContext(MenuGroupContext);
|
|
639
|
+
const register = group?.registerLabel;
|
|
640
|
+
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
if (register == null) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
register(true);
|
|
646
|
+
return () => register(false);
|
|
647
|
+
}, [register]);
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<div {...rest} id={group?.labelId} role="presentation">
|
|
651
|
+
{children}
|
|
652
|
+
</div>
|
|
653
|
+
);
|
|
654
|
+
}
|