@uniflowed/ui 0.0.0-alpha.12 → 0.0.0-alpha.14

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 CHANGED
@@ -20,6 +20,31 @@
20
20
  // * `Tab` closes the menu and carries on through the page, rather than
21
21
  // walking the reader through thirty items they have already dismissed.
22
22
  //
23
+ // # This is shadcn's Dropdown Menu
24
+ //
25
+ // Under that name it is a fourth component; here it is this one. A dropdown
26
+ // menu is a menu whose trigger is a button, which is what `Menu.Trigger` is, so
27
+ // there is no second module and no alias export — a second spelling of a
28
+ // component is a second surface to keep in step, and `index.js` argues against
29
+ // one at greater length. `context-menu.js` and `menubar.js` are the two that
30
+ // genuinely differ, and each of their headers says in what.
31
+ //
32
+ // # Choosing an item, and the item that should not close the menu
33
+ //
34
+ // `onSelect` receives the click and may answer it. `preventDefault()` means "I
35
+ // handled this and the menu stays open", which is the contract
36
+ // `internal/merge-props.js` already uses between a caller's handler and a
37
+ // component's, and the one Radix settled on for this exact question. A
38
+ // `closeOnSelect` prop is the same answer given once for a part rather than per
39
+ // press.
40
+ //
41
+ // The defaults differ between the item kinds because the platform's do. A
42
+ // command closes the menu — running it and leaving the menu open is a state no
43
+ // native menu has been in. A *checkable* item does not: "show hidden files"
44
+ // toggled three times is one visit to the menu everywhere except in a component
45
+ // library, and a checkbox that closed the menu would make checking three boxes
46
+ // mean opening the menu three times.
47
+ //
23
48
  // # Focus moves; `aria-activedescendant` does not appear here
24
49
  //
25
50
  // A menu moves *real* DOM focus onto its items. That is what WAI-ARIA
@@ -39,6 +64,27 @@
39
64
  // were aiming at. Keyboard and click open a submenu; a deliberate hover
40
65
  // implementation is tracked work, not a line to be added carelessly.
41
66
  //
67
+ // # Where the menu goes
68
+ //
69
+ // `internal/anchor.js`, the same module `Popover.Body` uses, and adopting it
70
+ // here rather than writing a second one is most of ubugeeei-prod/uf#256. Two
71
+ // things about a menu were wrong before it and are worth naming, because
72
+ // neither looks like a positioning bug:
73
+ //
74
+ // * A menu in a table row, a card, or anything else with `overflow: hidden`
75
+ // was cut off at that box's edge. It is `position: fixed` now, so the
76
+ // clipping ancestor is not its business.
77
+ // * A menu whose trigger sat near the bottom of the page opened downwards,
78
+ // off the screen, and the reader saw nothing at all.
79
+ //
80
+ // A submenu asks for `side="inline-end"` rather than `right`, which is the same
81
+ // answer `submenuKeys` gives about the *keys*: the submenu opens the way the
82
+ // page reads, and the arrow that opens it points at where it went.
83
+ //
84
+ // A context menu opens at a point instead, which is the one thing the
85
+ // positioner did not do; `internal/menu-tree.js` carries that rectangle and
86
+ // `internal/anchor.js` says what it replaces.
87
+ //
42
88
  // # Items are found in the document, not in a registry
43
89
  //
44
90
  // `internal/roving-focus.js` explains why. The short version is that mount
@@ -50,6 +96,7 @@
50
96
  import * as React from "@uniflowed/react";
51
97
  import {
52
98
  createContext,
99
+ useCallback,
53
100
  useContext,
54
101
  useEffect,
55
102
  useId,
@@ -59,6 +106,8 @@ import {
59
106
  } from "@uniflowed/react";
60
107
  import { useStableCallback } from "@uniflowed/hooks/lifecycle";
61
108
 
109
+ import type { Align, LogicalSide } from "./internal/anchor.js";
110
+ import { useAnchor } from "./internal/anchor.js";
62
111
  import type { Rest } from "./internal/merge-props.js";
63
112
  import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
64
113
  import {
@@ -71,78 +120,35 @@ import {
71
120
  useTypeahead,
72
121
  } from "./internal/roving-focus.js";
73
122
  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);
123
+ import {
124
+ ITEM_SELECTOR,
125
+ MENU_SELECTOR,
126
+ MenuAnchorContext,
127
+ MenuContext,
128
+ MenuLevel,
129
+ MenuListContext,
130
+ closeTree,
131
+ submenuKeys,
132
+ useMenu,
133
+ useTriggerRegistration,
134
+ } from "./internal/menu-tree.js";
135
+
136
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
132
137
 
133
138
  /**
134
- * The roving tab stop of one open menu.
139
+ * The part of a click a menu item's `onSelect` may read and answer.
135
140
  *
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`.
141
+ * Inexact, because what arrives is React's synthetic event and this names only
142
+ * the two members the contract is about: calling `preventDefault()` keeps the
143
+ * menu open, and the component reads `defaultPrevented` afterwards to find out.
144
+ * A caller who wants the rest of the event has it — this is the promise, not
145
+ * the object.
139
146
  */
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);
147
+ export type MenuSelect = {
148
+ readonly defaultPrevented: boolean,
149
+ readonly preventDefault: () => mixed,
150
+ ...
151
+ };
146
152
 
147
153
  /** The id of a group's label, so `Menu.Group` only claims one that exists. */
148
154
  type MenuGroupState = {|
@@ -152,54 +158,13 @@ type MenuGroupState = {|
152
158
 
153
159
  const MenuGroupContext: React.Context<MenuGroupState | null> = createContext(null);
154
160
 
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
- }
161
+ /** What a `Menu.RadioGroup` tells the items inside it. */
162
+ type MenuRadioState = {|
163
+ readonly value: string | null,
164
+ readonly choose: (value: string) => void,
165
+ |};
190
166
 
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
- }
167
+ const MenuRadioContext: React.Context<MenuRadioState | null> = createContext(null);
203
168
 
204
169
  /**
205
170
  * A menu and its trigger.
@@ -245,37 +210,6 @@ export component MenuSub(
245
210
  );
246
211
  }
247
212
 
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
213
  /** The button that opens the menu. */
280
214
  export component MenuTrigger(children: React.Node, ...rest: Rest) {
281
215
  const menu = useMenu("Menu.Trigger");
@@ -327,13 +261,29 @@ export component MenuTrigger(children: React.Node, ...rest: Rest) {
327
261
  * `Space` from being buttons.
328
262
  */
329
263
  export component MenuBody(
330
- children: renders* (MenuItem | MenuSeparator | MenuGroup | MenuSub),
264
+ children: renders* (
265
+ | MenuItem
266
+ | MenuCheckboxItem
267
+ | MenuRadioGroup
268
+ | MenuSeparator
269
+ | MenuGroup
270
+ | MenuSub
271
+ ),
272
+ align?: Align = "start",
273
+ alignOffset?: number = 0,
274
+ avoidCollisions?: boolean = true,
275
+ collisionPadding?: number = 0,
276
+ side?: LogicalSide,
277
+ sideOffset?: number = 0,
331
278
  ...rest: Rest
332
279
  ) {
333
280
  const menu = useMenu("Menu.Body");
334
281
  const bodyRef = useRef<HTMLElement | null>(null);
335
282
  const [activeId, setActiveId] = useState<string | null>(null);
336
283
  const typeahead = useTypeahead();
284
+ // A point to open at, when whatever opened this menu was a pointer rather
285
+ // than a button. Null for every menu that hangs off a trigger.
286
+ const point = useContext(MenuAnchorContext);
337
287
 
338
288
  // Pulled out because they are stable for the life of the menu, which is what
339
289
  // lets the effect below depend on `open` alone. Keyed on the context object
@@ -343,6 +293,23 @@ export component MenuBody(
343
293
  const pendingFocus = menu.pendingFocus;
344
294
  const isRoot = menu.parent == null;
345
295
  const closeAll = useStableCallback(() => closeTree(menu));
296
+ // A root menu drops from its button; a submenu comes out of the side of the
297
+ // item that opened it, on the side the page reads towards. The default cannot
298
+ // be a parameter default because it is not a constant: it is the answer to
299
+ // "is this the outermost menu", which only this component knows.
300
+ const placement = side ?? (isRoot ? "bottom" : "inline-end");
301
+ const anchored = useAnchor({
302
+ align,
303
+ alignOffset,
304
+ anchorRect: point,
305
+ anchorRef: triggerRef,
306
+ avoidCollisions,
307
+ collisionPadding,
308
+ open: menu.open,
309
+ overlayRef: bodyRef,
310
+ side: placement,
311
+ sideOffset,
312
+ });
346
313
  // Set when the menu was dismissed by a press somewhere else, so the cleanup
347
314
  // knows not to drag focus back to the trigger the reader just left.
348
315
  const dismissed = useRef(false);
@@ -418,6 +385,8 @@ export component MenuBody(
418
385
  {...passed}
419
386
  aria-labelledby={menu.triggered ? `${menu.base}-trigger` : undefined}
420
387
  aria-orientation="vertical"
388
+ data-align={anchored.align}
389
+ data-side={anchored.side}
421
390
  id={`${menu.base}-body`}
422
391
  onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
423
392
  const body: $FlowFixMe = event.currentTarget;
@@ -493,6 +462,52 @@ export component MenuBody(
493
462
  );
494
463
  }
495
464
 
465
+ /**
466
+ * Everything an item of any of the three kinds needs from the menu around it.
467
+ *
468
+ * One hook rather than three copies, because the three differ in their role and
469
+ * their state and in nothing else: the same id, the same roving tab stop, the
470
+ * same "a disabled item is announced and stepped over", and the same rule about
471
+ * when a press closes the tree.
472
+ */
473
+ hook useMenuItem(
474
+ part: string,
475
+ disabled: boolean,
476
+ closeOnSelect: boolean,
477
+ onSelect: ((event: MenuSelect) => mixed) | void,
478
+ act: (() => void) | void,
479
+ ): {|
480
+ readonly id: string,
481
+ readonly onClick: (event: MenuSelect) => void,
482
+ readonly onFocus: () => void,
483
+ readonly tabIndex: number,
484
+ |} {
485
+ const menu = useMenu(part);
486
+ const list = useContext(MenuListContext);
487
+ const id = useId();
488
+ const setActiveId = list?.setActiveId;
489
+
490
+ return {
491
+ id,
492
+ onClick: (event: MenuSelect) => {
493
+ if (disabled) {
494
+ return;
495
+ }
496
+ act?.();
497
+ onSelect?.(event);
498
+ // The caller's answer, read after they have had the event: a
499
+ // `preventDefault()` in `onSelect` is "I handled this, leave the menu
500
+ // open", which is the same sentence `composeHandlers` reads between a
501
+ // caller's handler and this package's.
502
+ if (closeOnSelect && !event.defaultPrevented) {
503
+ closeTree(menu);
504
+ }
505
+ },
506
+ onFocus: () => setActiveId?.(id),
507
+ tabIndex: list?.activeId === id ? 0 : -1,
508
+ };
509
+ }
510
+
496
511
  /**
497
512
  * One command in the menu.
498
513
  *
@@ -505,33 +520,164 @@ export component MenuBody(
505
520
  export component MenuItem(
506
521
  children: React.Node,
507
522
  disabled?: boolean = false,
508
- onSelect?: () => mixed,
523
+ closeOnSelect?: boolean = true,
524
+ onSelect?: (event: MenuSelect) => mixed,
509
525
  ...rest: Rest
510
526
  ) {
511
- const menu = useMenu("Menu.Item");
512
- const list = useContext(MenuListContext);
513
- const id = useId();
527
+ const item = useMenuItem("Menu.Item", disabled, closeOnSelect, onSelect, undefined);
514
528
  const passed = withoutComposed(rest, ["onClick", "onFocus"]);
515
- const setActiveId = list?.setActiveId;
516
529
 
517
530
  return (
518
531
  <button
519
532
  {...passed}
520
533
  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
- })}
534
+ id={item.id}
535
+ onClick={composeHandlers(rest.onClick, item.onClick)}
529
536
  // The roving tab stop follows real focus rather than leading it, so a
530
537
  // pointer that moves focus and a key that moves focus agree without the
531
538
  // two of them having to be kept in step by hand.
532
- onFocus={composeHandlers(rest.onFocus, () => setActiveId?.(id))}
539
+ onFocus={composeHandlers(rest.onFocus, item.onFocus)}
533
540
  role="menuitem"
534
- tabIndex={list?.activeId === id ? 0 : -1}
541
+ tabIndex={item.tabIndex}
542
+ type="button"
543
+ >
544
+ {children}
545
+ </button>
546
+ );
547
+ }
548
+
549
+ /**
550
+ * An item that carries a state of its own: "show hidden files".
551
+ *
552
+ * `role="menuitemcheckbox"` with `aria-checked`, which is the role the arrow
553
+ * keys and the typeahead have always stepped across — `ITEM_SELECTOR` named it
554
+ * before there was a component that rendered it. What a caller could not
555
+ * hand-roll on `Menu.Item` is the rest: the controlled-and-uncontrolled
556
+ * contract `internal/controlled-state.js` states for everything here, and a
557
+ * press that does *not* dismiss the menu.
558
+ *
559
+ * There is no third state. `aria-checked="mixed"` belongs to a checkbox that
560
+ * summarises other checkboxes — `checkbox.js` has it, and a menu item is a
561
+ * command rather than a summary of a table's rows.
562
+ */
563
+ export component MenuCheckboxItem(
564
+ children: React.Node,
565
+ checked?: boolean,
566
+ defaultChecked?: boolean = false,
567
+ onCheckedChange?: (checked: boolean) => void,
568
+ disabled?: boolean = false,
569
+ // A menu the reader is still ticking boxes in stays open; see the module
570
+ // header for why this default is the opposite of `Menu.Item`'s.
571
+ closeOnSelect?: boolean = false,
572
+ onSelect?: (event: MenuSelect) => mixed,
573
+ ...rest: Rest
574
+ ) {
575
+ const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
576
+ const toggle = useCallback(() => setOn(!on), [on, setOn]);
577
+ const item = useMenuItem("Menu.CheckboxItem", disabled, closeOnSelect, onSelect, toggle);
578
+ const passed = withoutComposed(rest, ["onClick", "onFocus"]);
579
+
580
+ return (
581
+ <button
582
+ {...passed}
583
+ aria-checked={on ? "true" : "false"}
584
+ aria-disabled={disabled ? "true" : undefined}
585
+ id={item.id}
586
+ onClick={composeHandlers(rest.onClick, item.onClick)}
587
+ onFocus={composeHandlers(rest.onFocus, item.onFocus)}
588
+ role="menuitemcheckbox"
589
+ tabIndex={item.tabIndex}
590
+ type="button"
591
+ >
592
+ {children}
593
+ </button>
594
+ );
595
+ }
596
+
597
+ /**
598
+ * A set of items of which exactly one is chosen.
599
+ *
600
+ * The *group* owns the value, which is what makes this a component rather than
601
+ * a convention: `aria-checked="true"` has to be on one item and `"false"` on
602
+ * the others, and a caller holding a value per item gets two checked ones the
603
+ * first time a render is skipped. `role="group"` is what ties them together for
604
+ * a reader — the items are `menuitemradio`, and a reader is told "2 of 3".
605
+ *
606
+ * `onValueChange` promises a `string` while the state is `string | null`, for
607
+ * the reason `radio-group.js` gives at greater length: "nothing chosen yet" is
608
+ * a state the group starts in and never an event it reports, because no gesture
609
+ * inside it unchooses an answer.
610
+ */
611
+ export component MenuRadioGroup(
612
+ children: renders* (MenuRadioItem | MenuLabel | MenuSeparator),
613
+ defaultValue?: string | null = null,
614
+ value?: string | null,
615
+ onValueChange?: (value: string) => void,
616
+ ...rest: Rest
617
+ ) {
618
+ const base = useId();
619
+ const [labelled, setLabelled] = useState(false);
620
+ const report = useCallback(
621
+ (next: string | null) => {
622
+ if (next != null) {
623
+ onValueChange?.(next);
624
+ }
625
+ },
626
+ [onValueChange],
627
+ );
628
+ const [selected, select] = useControlled<string | null>(value, defaultValue, report);
629
+
630
+ const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
631
+ const radio = useMemo(
632
+ () => ({ value: selected, choose: (next: string) => select(next) }),
633
+ [selected, select],
634
+ );
635
+
636
+ return (
637
+ <MenuGroupContext.Provider value={group}>
638
+ <MenuRadioContext.Provider value={radio}>
639
+ <div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
640
+ {children}
641
+ </div>
642
+ </MenuRadioContext.Provider>
643
+ </MenuGroupContext.Provider>
644
+ );
645
+ }
646
+
647
+ /**
648
+ * One answer in a `Menu.RadioGroup`.
649
+ *
650
+ * Choosing it reports the group's new value and leaves the menu open, which is
651
+ * what a sort order or a zoom level in a native menu does; `closeOnSelect` is
652
+ * the way to say otherwise for a choice that ends the visit.
653
+ */
654
+ export component MenuRadioItem(
655
+ children: React.Node,
656
+ value: string,
657
+ disabled?: boolean = false,
658
+ closeOnSelect?: boolean = false,
659
+ onSelect?: (event: MenuSelect) => mixed,
660
+ ...rest: Rest
661
+ ) {
662
+ const group = useContext(MenuRadioContext);
663
+ if (group == null) {
664
+ throw new Error("Menu.RadioItem must be rendered inside a Menu.RadioGroup");
665
+ }
666
+ const choose = group.choose;
667
+ const pick = useCallback(() => choose(value), [choose, value]);
668
+ const item = useMenuItem("Menu.RadioItem", disabled, closeOnSelect, onSelect, pick);
669
+ const passed = withoutComposed(rest, ["onClick", "onFocus"]);
670
+
671
+ return (
672
+ <button
673
+ {...passed}
674
+ aria-checked={group.value === value ? "true" : "false"}
675
+ aria-disabled={disabled ? "true" : undefined}
676
+ id={item.id}
677
+ onClick={composeHandlers(rest.onClick, item.onClick)}
678
+ onFocus={composeHandlers(rest.onFocus, item.onFocus)}
679
+ role="menuitemradio"
680
+ tabIndex={item.tabIndex}
535
681
  type="button"
536
682
  >
537
683
  {children}
@@ -628,7 +774,7 @@ export component MenuGroup(children: React.Node, ...rest: Rest) {
628
774
  }
629
775
 
630
776
  /**
631
- * The heading of a `Menu.Group`.
777
+ * The heading of a `Menu.Group` or a `Menu.RadioGroup`.
632
778
  *
633
779
  * `role="presentation"` because the group already carries the name: leaving it
634
780
  * as ordinary content would have a reader hear the heading once as the group's