@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/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
+ }