@uniflowed/ui 0.0.0-alpha.13 → 0.0.0-alpha.15
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 +21 -3
- package/checkbox.js +188 -10
- package/collapsible.js +29 -12
- package/combobox.js +4 -4
- package/context-menu.js +198 -0
- package/field.js +192 -25
- package/index.js +148 -4
- package/internal/anchor.js +24 -1
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/menu.js +264 -163
- package/menubar.js +285 -0
- package/package.json +6 -4
- package/select.js +19 -1
- package/switch.js +5 -3
- package/toggle.js +3 -2
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
|
|
@@ -56,6 +81,10 @@
|
|
|
56
81
|
// answer `submenuKeys` gives about the *keys*: the submenu opens the way the
|
|
57
82
|
// page reads, and the arrow that opens it points at where it went.
|
|
58
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
|
+
//
|
|
59
88
|
// # Items are found in the document, not in a registry
|
|
60
89
|
//
|
|
61
90
|
// `internal/roving-focus.js` explains why. The short version is that mount
|
|
@@ -67,6 +96,7 @@
|
|
|
67
96
|
import * as React from "@uniflowed/react";
|
|
68
97
|
import {
|
|
69
98
|
createContext,
|
|
99
|
+
useCallback,
|
|
70
100
|
useContext,
|
|
71
101
|
useEffect,
|
|
72
102
|
useId,
|
|
@@ -90,80 +120,35 @@ import {
|
|
|
90
120
|
useTypeahead,
|
|
91
121
|
} from "./internal/roving-focus.js";
|
|
92
122
|
import { useControlled } from "./internal/controlled-state.js";
|
|
93
|
-
import
|
|
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";
|
|
94
135
|
|
|
95
136
|
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
96
137
|
|
|
97
138
|
/**
|
|
98
|
-
*
|
|
99
|
-
* kinds a caller may write themselves. The keyboard has to move between all of
|
|
100
|
-
* them, so the selector names all of them rather than only what this package
|
|
101
|
-
* ships.
|
|
102
|
-
*/
|
|
103
|
-
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
|
|
104
|
-
|
|
105
|
-
/** What owns an item: the nearest menu, so a submenu keeps its own. */
|
|
106
|
-
const MENU_SELECTOR = '[role="menu"]';
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Which arrow key opens a submenu, and which closes it.
|
|
110
|
-
*
|
|
111
|
-
* The WAI-ARIA menu pattern puts a submenu on the *inline end*, so it opens to
|
|
112
|
-
* the right of a left-to-right menu and to the left of a right-to-left one, and
|
|
113
|
-
* the key that opens it is the one pointing at it. Written out as
|
|
114
|
-
* `ArrowRight` to open and `ArrowLeft` to close, an RTL reader pressed the key
|
|
115
|
-
* aimed at the submenu and closed the menu they were standing in — which is
|
|
116
|
-
* worse than nothing happening, because it loses their place.
|
|
117
|
-
*/
|
|
118
|
-
function submenuKeys(direction: Direction): {| readonly open: string, readonly close: string |} {
|
|
119
|
-
return direction === "rtl"
|
|
120
|
-
? { open: "ArrowLeft", close: "ArrowRight" }
|
|
121
|
-
: { open: "ArrowRight", close: "ArrowLeft" };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
type MenuState = {|
|
|
125
|
-
readonly base: string,
|
|
126
|
-
readonly open: boolean,
|
|
127
|
-
readonly setOpen: (open: boolean) => void,
|
|
128
|
-
/** What opened this menu, and what focus goes back to when it closes. */
|
|
129
|
-
readonly triggerRef: { current: HTMLElement | null },
|
|
130
|
-
/**
|
|
131
|
-
* Which end the menu should open onto, written by whatever opened it.
|
|
132
|
-
*
|
|
133
|
-
* A ref rather than state because it is an instruction for the next commit,
|
|
134
|
-
* not a value anything renders: `ArrowUp` on a closed menu opens it *and*
|
|
135
|
-
* lands on the last item, and re-rendering the trigger to say so would be a
|
|
136
|
-
* render whose only purpose is to carry a message to an effect.
|
|
137
|
-
*/
|
|
138
|
-
readonly pendingFocus: { current: "first" | "last" | null },
|
|
139
|
-
/** The menu this one hangs off, or null for the outermost. */
|
|
140
|
-
readonly parent: MenuState | null,
|
|
141
|
-
/**
|
|
142
|
-
* Whether a trigger is rendered, so the body only names one that exists.
|
|
143
|
-
*
|
|
144
|
-
* A menu opened by `defaultOpen` in a page that never renders a trigger is a
|
|
145
|
-
* real arrangement, and an `aria-labelledby` pointing at the id that trigger
|
|
146
|
-
* *would* have had makes a screen reader announce nothing at all.
|
|
147
|
-
*/
|
|
148
|
-
readonly triggered: boolean,
|
|
149
|
-
readonly registerTrigger: (present: boolean) => void,
|
|
150
|
-
|};
|
|
151
|
-
|
|
152
|
-
const MenuContext: React.Context<MenuState | null> = createContext(null);
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* The roving tab stop of one open menu.
|
|
139
|
+
* The part of a click a menu item's `onSelect` may read and answer.
|
|
156
140
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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.
|
|
160
146
|
*/
|
|
161
|
-
type
|
|
162
|
-
readonly
|
|
163
|
-
readonly
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const MenuListContext: React.Context<MenuListState | null> = createContext(null);
|
|
147
|
+
export type MenuSelect = {
|
|
148
|
+
readonly defaultPrevented: boolean,
|
|
149
|
+
readonly preventDefault: () => mixed,
|
|
150
|
+
...
|
|
151
|
+
};
|
|
167
152
|
|
|
168
153
|
/** The id of a group's label, so `Menu.Group` only claims one that exists. */
|
|
169
154
|
type MenuGroupState = {|
|
|
@@ -173,54 +158,13 @@ type MenuGroupState = {|
|
|
|
173
158
|
|
|
174
159
|
const MenuGroupContext: React.Context<MenuGroupState | null> = createContext(null);
|
|
175
160
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return state;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Tell the menu that a trigger for it is in the document.
|
|
186
|
-
*
|
|
187
|
-
* `Menu.Body` names its trigger with `aria-labelledby`, and it may only do that
|
|
188
|
-
* while there is one to name — a menu opened by `defaultOpen` in a page with no
|
|
189
|
-
* trigger would otherwise point at an id nothing has, and a screen reader given
|
|
190
|
-
* a dangling `aria-labelledby` announces nothing at all rather than falling back
|
|
191
|
-
* to the element's own content.
|
|
192
|
-
*/
|
|
193
|
-
hook useTriggerRegistration(menu: MenuState): void {
|
|
194
|
-
const register = menu.registerTrigger;
|
|
195
|
-
useEffect(() => {
|
|
196
|
-
register(true);
|
|
197
|
-
return () => register(false);
|
|
198
|
-
}, [register]);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Every menu from `menu` outwards, innermost first. */
|
|
202
|
-
function ancestry(menu: MenuState): Array<MenuState> {
|
|
203
|
-
const chain = [];
|
|
204
|
-
let at: MenuState | null = menu;
|
|
205
|
-
while (at != null) {
|
|
206
|
-
chain.push(at);
|
|
207
|
-
at = at.parent;
|
|
208
|
-
}
|
|
209
|
-
return chain;
|
|
210
|
-
}
|
|
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
|
+
|};
|
|
211
166
|
|
|
212
|
-
|
|
213
|
-
* Close this menu and every menu it hangs off.
|
|
214
|
-
*
|
|
215
|
-
* Choosing an item in a submenu dismisses the whole thing — leaving the parent
|
|
216
|
-
* menu open after a command has run is a state no native menu has ever been in,
|
|
217
|
-
* and it leaves the reader looking at a menu whose action already happened.
|
|
218
|
-
*/
|
|
219
|
-
function closeTree(menu: MenuState): void {
|
|
220
|
-
for (const each of ancestry(menu)) {
|
|
221
|
-
each.setOpen(false);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
167
|
+
const MenuRadioContext: React.Context<MenuRadioState | null> = createContext(null);
|
|
224
168
|
|
|
225
169
|
/**
|
|
226
170
|
* A menu and its trigger.
|
|
@@ -266,37 +210,6 @@ export component MenuSub(
|
|
|
266
210
|
);
|
|
267
211
|
}
|
|
268
212
|
|
|
269
|
-
/** One level of the menu tree. Shared by `Menu.Root` and `Menu.Sub`. */
|
|
270
|
-
component MenuLevel(
|
|
271
|
-
children: React.Node,
|
|
272
|
-
parent: MenuState | null,
|
|
273
|
-
defaultOpen: boolean,
|
|
274
|
-
open?: boolean,
|
|
275
|
-
onOpenChange?: (open: boolean) => void,
|
|
276
|
-
) {
|
|
277
|
-
const base = useId();
|
|
278
|
-
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
279
|
-
const triggerRef = useRef<HTMLElement | null>(null);
|
|
280
|
-
const pendingFocus = useRef<"first" | "last" | null>(null);
|
|
281
|
-
const [triggered, setTriggered] = useState(false);
|
|
282
|
-
|
|
283
|
-
const state = useMemo(
|
|
284
|
-
() => ({
|
|
285
|
-
base,
|
|
286
|
-
open: isOpen,
|
|
287
|
-
setOpen,
|
|
288
|
-
triggerRef,
|
|
289
|
-
pendingFocus,
|
|
290
|
-
parent,
|
|
291
|
-
triggered,
|
|
292
|
-
registerTrigger: setTriggered,
|
|
293
|
-
}),
|
|
294
|
-
[base, isOpen, setOpen, parent, triggered],
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
return <MenuContext.Provider value={state}>{children}</MenuContext.Provider>;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
213
|
/** The button that opens the menu. */
|
|
301
214
|
export component MenuTrigger(children: React.Node, ...rest: Rest) {
|
|
302
215
|
const menu = useMenu("Menu.Trigger");
|
|
@@ -348,7 +261,14 @@ export component MenuTrigger(children: React.Node, ...rest: Rest) {
|
|
|
348
261
|
* `Space` from being buttons.
|
|
349
262
|
*/
|
|
350
263
|
export component MenuBody(
|
|
351
|
-
children: renders* (
|
|
264
|
+
children: renders* (
|
|
265
|
+
| MenuItem
|
|
266
|
+
| MenuCheckboxItem
|
|
267
|
+
| MenuRadioGroup
|
|
268
|
+
| MenuSeparator
|
|
269
|
+
| MenuGroup
|
|
270
|
+
| MenuSub
|
|
271
|
+
),
|
|
352
272
|
align?: Align = "start",
|
|
353
273
|
alignOffset?: number = 0,
|
|
354
274
|
avoidCollisions?: boolean = true,
|
|
@@ -361,6 +281,9 @@ export component MenuBody(
|
|
|
361
281
|
const bodyRef = useRef<HTMLElement | null>(null);
|
|
362
282
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
363
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);
|
|
364
287
|
|
|
365
288
|
// Pulled out because they are stable for the life of the menu, which is what
|
|
366
289
|
// lets the effect below depend on `open` alone. Keyed on the context object
|
|
@@ -378,6 +301,7 @@ export component MenuBody(
|
|
|
378
301
|
const anchored = useAnchor({
|
|
379
302
|
align,
|
|
380
303
|
alignOffset,
|
|
304
|
+
anchorRect: point,
|
|
381
305
|
anchorRef: triggerRef,
|
|
382
306
|
avoidCollisions,
|
|
383
307
|
collisionPadding,
|
|
@@ -538,6 +462,52 @@ export component MenuBody(
|
|
|
538
462
|
);
|
|
539
463
|
}
|
|
540
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
|
+
|
|
541
511
|
/**
|
|
542
512
|
* One command in the menu.
|
|
543
513
|
*
|
|
@@ -550,33 +520,164 @@ export component MenuBody(
|
|
|
550
520
|
export component MenuItem(
|
|
551
521
|
children: React.Node,
|
|
552
522
|
disabled?: boolean = false,
|
|
553
|
-
|
|
523
|
+
closeOnSelect?: boolean = true,
|
|
524
|
+
onSelect?: (event: MenuSelect) => mixed,
|
|
554
525
|
...rest: Rest
|
|
555
526
|
) {
|
|
556
|
-
const
|
|
557
|
-
const list = useContext(MenuListContext);
|
|
558
|
-
const id = useId();
|
|
527
|
+
const item = useMenuItem("Menu.Item", disabled, closeOnSelect, onSelect, undefined);
|
|
559
528
|
const passed = withoutComposed(rest, ["onClick", "onFocus"]);
|
|
560
|
-
const setActiveId = list?.setActiveId;
|
|
561
529
|
|
|
562
530
|
return (
|
|
563
531
|
<button
|
|
564
532
|
{...passed}
|
|
565
533
|
aria-disabled={disabled ? "true" : undefined}
|
|
566
|
-
id={id}
|
|
567
|
-
onClick={composeHandlers(rest.onClick,
|
|
568
|
-
if (disabled) {
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
onSelect?.();
|
|
572
|
-
closeTree(menu);
|
|
573
|
-
})}
|
|
534
|
+
id={item.id}
|
|
535
|
+
onClick={composeHandlers(rest.onClick, item.onClick)}
|
|
574
536
|
// The roving tab stop follows real focus rather than leading it, so a
|
|
575
537
|
// pointer that moves focus and a key that moves focus agree without the
|
|
576
538
|
// two of them having to be kept in step by hand.
|
|
577
|
-
onFocus={composeHandlers(rest.onFocus,
|
|
539
|
+
onFocus={composeHandlers(rest.onFocus, item.onFocus)}
|
|
578
540
|
role="menuitem"
|
|
579
|
-
tabIndex={
|
|
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}
|
|
580
681
|
type="button"
|
|
581
682
|
>
|
|
582
683
|
{children}
|
|
@@ -673,7 +774,7 @@ export component MenuGroup(children: React.Node, ...rest: Rest) {
|
|
|
673
774
|
}
|
|
674
775
|
|
|
675
776
|
/**
|
|
676
|
-
* The heading of a `Menu.Group`.
|
|
777
|
+
* The heading of a `Menu.Group` or a `Menu.RadioGroup`.
|
|
677
778
|
*
|
|
678
779
|
* `role="presentation"` because the group already carries the name: leaving it
|
|
679
780
|
* as ordinary content would have a reader hear the heading once as the group's
|