@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.41
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 +362 -0
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +547 -0
- package/carousel.js +410 -0
- package/checkbox.js +216 -31
- package/collapsible.js +171 -0
- package/combobox.js +235 -47
- package/context-menu.js +215 -0
- package/date-picker.js +357 -0
- package/dialog.js +235 -197
- package/drawer.js +504 -0
- package/field.js +257 -42
- package/hover-card.js +334 -0
- package/index.js +1548 -24
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +565 -0
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +298 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +206 -7
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +207 -11
- package/menu.js +558 -340
- package/menubar.js +295 -0
- package/navigation-menu.js +251 -0
- package/package.json +8 -12
- package/pagination.js +209 -0
- package/popover.js +367 -0
- package/progress.js +91 -0
- package/radio-group.js +304 -0
- package/resizable.js +453 -0
- package/scroll-area.js +283 -0
- package/select.js +901 -0
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +411 -0
- package/switch.js +43 -34
- package/table.js +520 -0
- package/tabs.js +99 -96
- package/toast.js +594 -0
- package/toggle-group.js +284 -0
- package/toggle.js +105 -0
- package/tooltip.js +404 -0
package/menu.js
CHANGED
|
@@ -15,10 +15,36 @@
|
|
|
15
15
|
// * `Escape` closes *this* menu — the submenu if one is open, not the whole
|
|
16
16
|
// tree — and gives focus back to what opened it.
|
|
17
17
|
// * `ArrowRight` opens a submenu and lands on its first item; `ArrowLeft`
|
|
18
|
-
// closes it and comes back to the item that opened it
|
|
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*.
|
|
19
20
|
// * `Tab` closes the menu and carries on through the page, rather than
|
|
20
21
|
// walking the reader through thirty items they have already dismissed.
|
|
21
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
|
+
//
|
|
22
48
|
// # Focus moves; `aria-activedescendant` does not appear here
|
|
23
49
|
//
|
|
24
50
|
// A menu moves *real* DOM focus onto its items. That is what WAI-ARIA
|
|
@@ -38,6 +64,27 @@
|
|
|
38
64
|
// were aiming at. Keyboard and click open a submenu; a deliberate hover
|
|
39
65
|
// implementation is tracked work, not a line to be added carelessly.
|
|
40
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
|
+
//
|
|
41
88
|
// # Items are found in the document, not in a registry
|
|
42
89
|
//
|
|
43
90
|
// `internal/roving-focus.js` explains why. The short version is that mount
|
|
@@ -49,6 +96,7 @@
|
|
|
49
96
|
import * as React from "@uniflowed/react";
|
|
50
97
|
import {
|
|
51
98
|
createContext,
|
|
99
|
+
useCallback,
|
|
52
100
|
useContext,
|
|
53
101
|
useEffect,
|
|
54
102
|
useId,
|
|
@@ -58,8 +106,19 @@ import {
|
|
|
58
106
|
} from "@uniflowed/react";
|
|
59
107
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
60
108
|
|
|
61
|
-
import {
|
|
109
|
+
import { useInteractOutside } from "./interactions.js";
|
|
110
|
+
|
|
111
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
112
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
113
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
62
114
|
import {
|
|
115
|
+
composeHandlers,
|
|
116
|
+
composeRefs,
|
|
117
|
+
withProps,
|
|
118
|
+
withoutComposed,
|
|
119
|
+
} from "./internal/merge-props.js";
|
|
120
|
+
import {
|
|
121
|
+
directionOf,
|
|
63
122
|
indexOfActive,
|
|
64
123
|
isTypeaheadKey,
|
|
65
124
|
itemsOf,
|
|
@@ -68,61 +127,35 @@ import {
|
|
|
68
127
|
useTypeahead,
|
|
69
128
|
} from "./internal/roving-focus.js";
|
|
70
129
|
import { useControlled } from "./internal/controlled-state.js";
|
|
130
|
+
import {
|
|
131
|
+
ITEM_SELECTOR,
|
|
132
|
+
MENU_SELECTOR,
|
|
133
|
+
MenuAnchorContext,
|
|
134
|
+
MenuContext,
|
|
135
|
+
MenuLevel,
|
|
136
|
+
MenuListContext,
|
|
137
|
+
closeTree,
|
|
138
|
+
submenuKeys,
|
|
139
|
+
useMenu,
|
|
140
|
+
useTriggerRegistration,
|
|
141
|
+
} from "./internal/menu-tree.js";
|
|
142
|
+
|
|
143
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
71
144
|
|
|
72
145
|
/**
|
|
73
|
-
*
|
|
74
|
-
* kinds a caller may write themselves. The keyboard has to move between all of
|
|
75
|
-
* them, so the selector names all of them rather than only what this package
|
|
76
|
-
* ships.
|
|
77
|
-
*/
|
|
78
|
-
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
|
|
79
|
-
|
|
80
|
-
/** What owns an item: the nearest menu, so a submenu keeps its own. */
|
|
81
|
-
const MENU_SELECTOR = '[role="menu"]';
|
|
82
|
-
|
|
83
|
-
type MenuState = {|
|
|
84
|
-
readonly base: string,
|
|
85
|
-
readonly open: boolean,
|
|
86
|
-
readonly setOpen: (open: boolean) => void,
|
|
87
|
-
/** What opened this menu, and what focus goes back to when it closes. */
|
|
88
|
-
readonly triggerRef: { current: HTMLElement | null },
|
|
89
|
-
/**
|
|
90
|
-
* Which end the menu should open onto, written by whatever opened it.
|
|
91
|
-
*
|
|
92
|
-
* A ref rather than state because it is an instruction for the next commit,
|
|
93
|
-
* not a value anything renders: `ArrowUp` on a closed menu opens it *and*
|
|
94
|
-
* lands on the last item, and re-rendering the trigger to say so would be a
|
|
95
|
-
* render whose only purpose is to carry a message to an effect.
|
|
96
|
-
*/
|
|
97
|
-
readonly pendingFocus: { current: "first" | "last" | null },
|
|
98
|
-
/** The menu this one hangs off, or null for the outermost. */
|
|
99
|
-
readonly parent: MenuState | null,
|
|
100
|
-
/**
|
|
101
|
-
* Whether a trigger is rendered, so the body only names one that exists.
|
|
102
|
-
*
|
|
103
|
-
* A menu opened by `defaultOpen` in a page that never renders a trigger is a
|
|
104
|
-
* real arrangement, and an `aria-labelledby` pointing at the id that trigger
|
|
105
|
-
* *would* have had makes a screen reader announce nothing at all.
|
|
106
|
-
*/
|
|
107
|
-
readonly triggered: boolean,
|
|
108
|
-
readonly registerTrigger: (present: boolean) => void,
|
|
109
|
-
|};
|
|
110
|
-
|
|
111
|
-
const MenuContext: React.Context<MenuState | null> = createContext(null);
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* The roving tab stop of one open menu.
|
|
146
|
+
* The part of a click a menu item's `onSelect` may read and answer.
|
|
115
147
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
148
|
+
* Inexact, because what arrives is React's synthetic event and this names only
|
|
149
|
+
* the two members the contract is about: calling `preventDefault()` keeps the
|
|
150
|
+
* menu open, and the component reads `defaultPrevented` afterwards to find out.
|
|
151
|
+
* A caller who wants the rest of the event has it — this is the promise, not
|
|
152
|
+
* the object.
|
|
119
153
|
*/
|
|
120
|
-
type
|
|
121
|
-
readonly
|
|
122
|
-
readonly
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const MenuListContext: React.Context<MenuListState | null> = createContext(null);
|
|
154
|
+
export type MenuSelect = {
|
|
155
|
+
readonly defaultPrevented: boolean,
|
|
156
|
+
readonly preventDefault: () => mixed,
|
|
157
|
+
...
|
|
158
|
+
};
|
|
126
159
|
|
|
127
160
|
/** The id of a group's label, so `Menu.Group` only claims one that exists. */
|
|
128
161
|
type MenuGroupState = {|
|
|
@@ -132,54 +165,13 @@ type MenuGroupState = {|
|
|
|
132
165
|
|
|
133
166
|
const MenuGroupContext: React.Context<MenuGroupState | null> = createContext(null);
|
|
134
167
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return state;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Tell the menu that a trigger for it is in the document.
|
|
145
|
-
*
|
|
146
|
-
* `Menu.Body` names its trigger with `aria-labelledby`, and it may only do that
|
|
147
|
-
* while there is one to name — a menu opened by `defaultOpen` in a page with no
|
|
148
|
-
* trigger would otherwise point at an id nothing has, and a screen reader given
|
|
149
|
-
* a dangling `aria-labelledby` announces nothing at all rather than falling back
|
|
150
|
-
* to the element's own content.
|
|
151
|
-
*/
|
|
152
|
-
hook useTriggerRegistration(menu: MenuState): void {
|
|
153
|
-
const register = menu.registerTrigger;
|
|
154
|
-
useEffect(() => {
|
|
155
|
-
register(true);
|
|
156
|
-
return () => register(false);
|
|
157
|
-
}, [register]);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Every menu from `menu` outwards, innermost first. */
|
|
161
|
-
function ancestry(menu: MenuState): Array<MenuState> {
|
|
162
|
-
const chain = [];
|
|
163
|
-
let at: MenuState | null = menu;
|
|
164
|
-
while (at != null) {
|
|
165
|
-
chain.push(at);
|
|
166
|
-
at = at.parent;
|
|
167
|
-
}
|
|
168
|
-
return chain;
|
|
169
|
-
}
|
|
168
|
+
/** What a `Menu.RadioGroup` tells the items inside it. */
|
|
169
|
+
type MenuRadioState = {|
|
|
170
|
+
readonly value: string | null,
|
|
171
|
+
readonly choose: (value: string) => void,
|
|
172
|
+
|};
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
* Close this menu and every menu it hangs off.
|
|
173
|
-
*
|
|
174
|
-
* Choosing an item in a submenu dismisses the whole thing — leaving the parent
|
|
175
|
-
* menu open after a command has run is a state no native menu has ever been in,
|
|
176
|
-
* and it leaves the reader looking at a menu whose action already happened.
|
|
177
|
-
*/
|
|
178
|
-
function closeTree(menu: MenuState): void {
|
|
179
|
-
for (const each of ancestry(menu)) {
|
|
180
|
-
each.setOpen(false);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
174
|
+
const MenuRadioContext: React.Context<MenuRadioState | null> = createContext(null);
|
|
183
175
|
|
|
184
176
|
/**
|
|
185
177
|
* A menu and its trigger.
|
|
@@ -225,77 +217,48 @@ export component MenuSub(
|
|
|
225
217
|
);
|
|
226
218
|
}
|
|
227
219
|
|
|
228
|
-
/** One level of the menu tree. Shared by `Menu.Root` and `Menu.Sub`. */
|
|
229
|
-
component MenuLevel(
|
|
230
|
-
children: React.Node,
|
|
231
|
-
parent: MenuState | null,
|
|
232
|
-
defaultOpen: boolean,
|
|
233
|
-
open?: boolean,
|
|
234
|
-
onOpenChange?: (open: boolean) => void,
|
|
235
|
-
) {
|
|
236
|
-
const base = useId();
|
|
237
|
-
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
238
|
-
const triggerRef = useRef<HTMLElement | null>(null);
|
|
239
|
-
const pendingFocus = useRef<"first" | "last" | null>(null);
|
|
240
|
-
const [triggered, setTriggered] = useState(false);
|
|
241
|
-
|
|
242
|
-
const state = useMemo(
|
|
243
|
-
() => ({
|
|
244
|
-
base,
|
|
245
|
-
open: isOpen,
|
|
246
|
-
setOpen,
|
|
247
|
-
triggerRef,
|
|
248
|
-
pendingFocus,
|
|
249
|
-
parent,
|
|
250
|
-
triggered,
|
|
251
|
-
registerTrigger: setTriggered,
|
|
252
|
-
}),
|
|
253
|
-
[base, isOpen, setOpen, parent, triggered],
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
return <MenuContext.Provider value={state}>{children}</MenuContext.Provider>;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
220
|
/** The button that opens the menu. */
|
|
260
|
-
export component MenuTrigger(children: React.Node, ...rest:
|
|
221
|
+
export component MenuTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
261
222
|
const menu = useMenu("Menu.Trigger");
|
|
262
|
-
const passed = withoutComposed(rest, ["onClick", "onKeyDown", "ref"]);
|
|
263
223
|
useTriggerRegistration(menu);
|
|
224
|
+
const props = withProps(withoutComposed(rest, ["onClick", "onKeyDown", "ref"]), {
|
|
225
|
+
// Named only while the menu is in the document, so a reader is never told
|
|
226
|
+
// to go somewhere that is not there.
|
|
227
|
+
"aria-controls": menu.open ? `${menu.base}-body` : undefined,
|
|
228
|
+
"aria-expanded": menu.open ? "true" : "false",
|
|
229
|
+
"aria-haspopup": "menu",
|
|
230
|
+
children,
|
|
231
|
+
id: `${menu.base}-trigger`,
|
|
232
|
+
onClick: composeHandlers(rest.onClick, () => menu.setOpen(!menu.open)),
|
|
233
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
234
|
+
// `ArrowUp` opening onto the *last* item is the behaviour that makes a
|
|
235
|
+
// long menu usable: the last entry is usually the destructive one, and
|
|
236
|
+
// reaching it should not mean arrowing past everything else.
|
|
237
|
+
const end = match (event.key) {
|
|
238
|
+
"ArrowDown" => "first",
|
|
239
|
+
"ArrowUp" => "last",
|
|
240
|
+
_ => null,
|
|
241
|
+
};
|
|
242
|
+
if (end == null) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
event.preventDefault();
|
|
246
|
+
// This is an instruction for the menu body after the opening commit.
|
|
247
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
248
|
+
menu.pendingFocusRef.current = end;
|
|
249
|
+
menu.setOpen(true);
|
|
250
|
+
}),
|
|
251
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
252
|
+
// React calls callback refs during commit; the menu body reads the trigger later.
|
|
253
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
254
|
+
menu.triggerRef.current = element;
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
264
257
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// to go somewhere that is not there.
|
|
270
|
-
aria-controls={menu.open ? `${menu.base}-body` : undefined}
|
|
271
|
-
aria-expanded={menu.open ? "true" : "false"}
|
|
272
|
-
aria-haspopup="menu"
|
|
273
|
-
id={`${menu.base}-trigger`}
|
|
274
|
-
onClick={composeHandlers(rest.onClick, () => menu.setOpen(!menu.open))}
|
|
275
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
276
|
-
// `ArrowUp` opening onto the *last* item is the behaviour that makes a
|
|
277
|
-
// long menu usable: the last entry is usually the destructive one, and
|
|
278
|
-
// reaching it should not mean arrowing past everything else.
|
|
279
|
-
const end = match (event.key) {
|
|
280
|
-
"ArrowDown" => "first",
|
|
281
|
-
"ArrowUp" => "last",
|
|
282
|
-
_ => null,
|
|
283
|
-
};
|
|
284
|
-
if (end == null) {
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
event.preventDefault();
|
|
288
|
-
menu.pendingFocus.current = end;
|
|
289
|
-
menu.setOpen(true);
|
|
290
|
-
})}
|
|
291
|
-
ref={composeRefs(rest.ref, (element) => {
|
|
292
|
-
menu.triggerRef.current = element;
|
|
293
|
-
})}
|
|
294
|
-
type="button"
|
|
295
|
-
>
|
|
296
|
-
{children}
|
|
297
|
-
</button>
|
|
298
|
-
);
|
|
258
|
+
if (render != null) {
|
|
259
|
+
return render(props);
|
|
260
|
+
}
|
|
261
|
+
return <button {...props} type="button" />;
|
|
299
262
|
}
|
|
300
263
|
|
|
301
264
|
/**
|
|
@@ -307,22 +270,64 @@ export component MenuTrigger(children: React.Node, ...rest: { readonly [string]:
|
|
|
307
270
|
* `Space` from being buttons.
|
|
308
271
|
*/
|
|
309
272
|
export component MenuBody(
|
|
310
|
-
children: renders* (
|
|
311
|
-
|
|
273
|
+
children: renders* (
|
|
274
|
+
| MenuItem
|
|
275
|
+
| MenuCheckboxItem
|
|
276
|
+
| MenuRadioGroup
|
|
277
|
+
| MenuSeparator
|
|
278
|
+
| MenuGroup
|
|
279
|
+
| MenuSub
|
|
280
|
+
),
|
|
281
|
+
align?: Align = "start",
|
|
282
|
+
alignOffset?: number = 0,
|
|
283
|
+
avoidCollisions?: boolean = true,
|
|
284
|
+
collisionPadding?: number = 0,
|
|
285
|
+
side?: LogicalSide,
|
|
286
|
+
sideOffset?: number = 0,
|
|
287
|
+
render?: RenderProp,
|
|
288
|
+
...rest: Rest
|
|
312
289
|
) {
|
|
313
290
|
const menu = useMenu("Menu.Body");
|
|
314
291
|
const bodyRef = useRef<HTMLElement | null>(null);
|
|
315
292
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
316
293
|
const typeahead = useTypeahead();
|
|
294
|
+
// A point to open at, when whatever opened this menu was a pointer rather
|
|
295
|
+
// than a button. Null for every menu that hangs off a trigger.
|
|
296
|
+
const point = useContext(MenuAnchorContext);
|
|
317
297
|
|
|
318
298
|
// Pulled out because they are stable for the life of the menu, which is what
|
|
319
299
|
// lets the effect below depend on `open` alone. Keyed on the context object
|
|
320
300
|
// it re-ran on every parent render and re-took focus each time, dragging the
|
|
321
301
|
// reader back to the first item while they were arrowing.
|
|
322
302
|
const triggerRef = menu.triggerRef;
|
|
323
|
-
const
|
|
303
|
+
const pendingFocusRef = menu.pendingFocusRef;
|
|
304
|
+
// `parent` is menu-tree metadata; no ref value is read during render.
|
|
305
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
324
306
|
const isRoot = menu.parent == null;
|
|
325
307
|
const closeAll = useStableCallback(() => closeTree(menu));
|
|
308
|
+
// A root menu drops from its button; a submenu comes out of the side of the
|
|
309
|
+
// item that opened it, on the side the page reads towards. The default cannot
|
|
310
|
+
// be a parameter default because it is not a constant: it is the answer to
|
|
311
|
+
// "is this the outermost menu", which only this component knows.
|
|
312
|
+
const placement = side ?? (isRoot ? "bottom" : "inline-end");
|
|
313
|
+
// useAnchor accepts ref objects and reads them from layout/effects.
|
|
314
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
315
|
+
const anchored = useAnchor({
|
|
316
|
+
align,
|
|
317
|
+
alignOffset,
|
|
318
|
+
anchorRect: point,
|
|
319
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
320
|
+
anchorRef: triggerRef,
|
|
321
|
+
avoidCollisions,
|
|
322
|
+
collisionPadding,
|
|
323
|
+
// `open` is menu-tree metadata; no ref value is read during render.
|
|
324
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
325
|
+
open: menu.open,
|
|
326
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
327
|
+
overlayRef: bodyRef,
|
|
328
|
+
side: placement,
|
|
329
|
+
sideOffset,
|
|
330
|
+
});
|
|
326
331
|
// Set when the menu was dismissed by a press somewhere else, so the cleanup
|
|
327
332
|
// knows not to drag focus back to the trigger the reader just left.
|
|
328
333
|
const dismissed = useRef(false);
|
|
@@ -335,8 +340,8 @@ export component MenuBody(
|
|
|
335
340
|
const document = body.ownerDocument;
|
|
336
341
|
const trigger = triggerRef.current;
|
|
337
342
|
|
|
338
|
-
const wanted =
|
|
339
|
-
|
|
343
|
+
const wanted = pendingFocusRef.current;
|
|
344
|
+
pendingFocusRef.current = null;
|
|
340
345
|
const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
|
|
341
346
|
const landing = moveTo(items, -1, wanted === "last" ? "last" : "first", false);
|
|
342
347
|
// The menu itself when it holds nothing focusable, so focus is inside it
|
|
@@ -346,30 +351,7 @@ export component MenuBody(
|
|
|
346
351
|
setActiveId(landing.id);
|
|
347
352
|
}
|
|
348
353
|
|
|
349
|
-
const onOutsidePress = (event: Event) => {
|
|
350
|
-
const target: $FlowFixMe = event.target;
|
|
351
|
-
if (target == null || body.contains(target)) {
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
// The trigger is outside the menu and is not "outside" for this purpose:
|
|
355
|
-
// closing here and letting the trigger's own click reopen made a press on
|
|
356
|
-
// the trigger a no-op that flickered.
|
|
357
|
-
if (trigger != null && trigger.contains(target)) {
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
dismissed.current = true;
|
|
361
|
-
closeAll();
|
|
362
|
-
};
|
|
363
|
-
// Only the outermost menu listens. A submenu closes with the tree, and two
|
|
364
|
-
// listeners would each answer the same press.
|
|
365
|
-
if (isRoot) {
|
|
366
|
-
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
354
|
return () => {
|
|
370
|
-
if (isRoot) {
|
|
371
|
-
document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
372
|
-
}
|
|
373
355
|
if (dismissed.current) {
|
|
374
356
|
dismissed.current = false;
|
|
375
357
|
return;
|
|
@@ -382,92 +364,171 @@ export component MenuBody(
|
|
|
382
364
|
trigger?.focus?.();
|
|
383
365
|
}
|
|
384
366
|
};
|
|
385
|
-
|
|
367
|
+
// The ref objects are stable; this effect reads them after the opening commit.
|
|
368
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
369
|
+
}, [menu.open, triggerRef, pendingFocusRef]);
|
|
370
|
+
|
|
371
|
+
// Only the outermost menu listens. A submenu closes with the tree, and two
|
|
372
|
+
// of these would each answer the same press.
|
|
373
|
+
// The outside-interaction hook accepts refs; it reads them from event handlers.
|
|
374
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
375
|
+
useInteractOutside({
|
|
376
|
+
// `open` is menu-tree metadata; the hook reads refs from event handlers.
|
|
377
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
378
|
+
isDisabled: !menu.open || !isRoot,
|
|
379
|
+
onInteractOutside: () => {
|
|
380
|
+
dismissed.current = true;
|
|
381
|
+
closeAll();
|
|
382
|
+
},
|
|
383
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
384
|
+
refs: [bodyRef, triggerRef],
|
|
385
|
+
});
|
|
386
386
|
|
|
387
387
|
const list = useMemo(() => ({ activeId, setActiveId }), [activeId]);
|
|
388
388
|
|
|
389
|
+
// `open` is menu-tree metadata; no ref value is read during render.
|
|
390
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
389
391
|
if (!menu.open) {
|
|
390
392
|
return null;
|
|
391
393
|
}
|
|
392
394
|
|
|
393
|
-
const
|
|
395
|
+
const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
|
|
396
|
+
// `triggered` and `base` are menu-tree metadata, not ref values.
|
|
397
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
398
|
+
"aria-labelledby": menu.triggered ? `${menu.base}-trigger` : undefined,
|
|
399
|
+
"aria-orientation": "vertical",
|
|
400
|
+
children,
|
|
401
|
+
"data-align": anchored.align,
|
|
402
|
+
"data-side": anchored.side,
|
|
403
|
+
// `base` is menu-tree metadata, not a ref value.
|
|
404
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
405
|
+
id: `${menu.base}-body`,
|
|
406
|
+
// Key handling moves real DOM focus and records the active item after events.
|
|
407
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
408
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
409
|
+
const body: $FlowFixMe = event.currentTarget;
|
|
410
|
+
const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
|
|
411
|
+
const at = indexOfActive(items, body.ownerDocument?.activeElement);
|
|
412
|
+
|
|
413
|
+
if (event.key === "Escape") {
|
|
414
|
+
event.preventDefault();
|
|
415
|
+
// This menu, not the one behind it and not the dialog around it.
|
|
416
|
+
// A submenu is a DOM descendant of its parent menu, so without this
|
|
417
|
+
// one Escape closed the whole tree at once.
|
|
418
|
+
event.stopPropagation();
|
|
419
|
+
menu.setOpen(false);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (event.key === "Tab") {
|
|
424
|
+
// Not prevented: the browser should carry on to the next control,
|
|
425
|
+
// which is what makes Tab a way *past* a menu rather than a way
|
|
426
|
+
// through its thirty items.
|
|
427
|
+
event.stopPropagation();
|
|
428
|
+
closeAll();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Asked once, here, and used for both questions below: which key
|
|
433
|
+
// closes this submenu, and — for a menu a caller has laid out
|
|
434
|
+
// horizontally one day — which way the arrows run.
|
|
435
|
+
const direction = directionOf(body);
|
|
436
|
+
|
|
437
|
+
if (!isRoot && event.key === submenuKeys(direction).close) {
|
|
438
|
+
event.preventDefault();
|
|
439
|
+
event.stopPropagation();
|
|
440
|
+
menu.setOpen(false);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const movement = movementFor(event.key, "vertical", direction);
|
|
445
|
+
if (movement != null) {
|
|
446
|
+
// Before moving, or the arrow also scrolls the page under the item
|
|
447
|
+
// that just took focus.
|
|
448
|
+
event.preventDefault();
|
|
449
|
+
event.stopPropagation();
|
|
450
|
+
const next = moveTo(items, at, movement, true);
|
|
451
|
+
if (next != null) {
|
|
452
|
+
next.focus();
|
|
453
|
+
setActiveId(next.id);
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (isTypeaheadKey(event)) {
|
|
459
|
+
const next = typeahead(items, at, event.key);
|
|
460
|
+
if (next != null) {
|
|
461
|
+
event.preventDefault();
|
|
462
|
+
event.stopPropagation();
|
|
463
|
+
next.focus();
|
|
464
|
+
setActiveId(next.id);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}),
|
|
468
|
+
// React calls callback refs during commit; placement and keyboard effects read it later.
|
|
469
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
470
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
471
|
+
bodyRef.current = element;
|
|
472
|
+
}),
|
|
473
|
+
role: "menu",
|
|
474
|
+
// So the menu can hold focus itself when it is empty, and so a press on
|
|
475
|
+
// its padding does not send focus to `<body>`.
|
|
476
|
+
tabIndex: -1,
|
|
477
|
+
});
|
|
394
478
|
|
|
395
479
|
return (
|
|
396
480
|
<MenuListContext.Provider value={list}>
|
|
397
|
-
<div
|
|
398
|
-
{...passed}
|
|
399
|
-
aria-labelledby={menu.triggered ? `${menu.base}-trigger` : undefined}
|
|
400
|
-
aria-orientation="vertical"
|
|
401
|
-
id={`${menu.base}-body`}
|
|
402
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
403
|
-
const body: $FlowFixMe = event.currentTarget;
|
|
404
|
-
const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
|
|
405
|
-
const at = indexOfActive(items, body.ownerDocument?.activeElement);
|
|
406
|
-
|
|
407
|
-
if (event.key === "Escape") {
|
|
408
|
-
event.preventDefault();
|
|
409
|
-
// This menu, not the one behind it and not the dialog around it.
|
|
410
|
-
// A submenu is a DOM descendant of its parent menu, so without this
|
|
411
|
-
// one Escape closed the whole tree at once.
|
|
412
|
-
event.stopPropagation();
|
|
413
|
-
menu.setOpen(false);
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (event.key === "Tab") {
|
|
418
|
-
// Not prevented: the browser should carry on to the next control,
|
|
419
|
-
// which is what makes Tab a way *past* a menu rather than a way
|
|
420
|
-
// through its thirty items.
|
|
421
|
-
event.stopPropagation();
|
|
422
|
-
closeAll();
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
if (!isRoot && event.key === "ArrowLeft") {
|
|
427
|
-
event.preventDefault();
|
|
428
|
-
event.stopPropagation();
|
|
429
|
-
menu.setOpen(false);
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const movement = movementFor(event.key, "vertical");
|
|
434
|
-
if (movement != null) {
|
|
435
|
-
// Before moving, or the arrow also scrolls the page under the item
|
|
436
|
-
// that just took focus.
|
|
437
|
-
event.preventDefault();
|
|
438
|
-
event.stopPropagation();
|
|
439
|
-
const next = moveTo(items, at, movement, true);
|
|
440
|
-
if (next != null) {
|
|
441
|
-
next.focus();
|
|
442
|
-
setActiveId(next.id);
|
|
443
|
-
}
|
|
444
|
-
return;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
if (isTypeaheadKey(event)) {
|
|
448
|
-
const next = typeahead(items, at, event.key);
|
|
449
|
-
if (next != null) {
|
|
450
|
-
event.preventDefault();
|
|
451
|
-
event.stopPropagation();
|
|
452
|
-
next.focus();
|
|
453
|
-
setActiveId(next.id);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
})}
|
|
457
|
-
ref={composeRefs(rest.ref, (element) => {
|
|
458
|
-
bodyRef.current = element;
|
|
459
|
-
})}
|
|
460
|
-
role="menu"
|
|
461
|
-
// So the menu can hold focus itself when it is empty, and so a press on
|
|
462
|
-
// its padding does not send focus to `<body>`.
|
|
463
|
-
tabIndex={-1}
|
|
464
|
-
>
|
|
465
|
-
{children}
|
|
466
|
-
</div>
|
|
481
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
467
482
|
</MenuListContext.Provider>
|
|
468
483
|
);
|
|
469
484
|
}
|
|
470
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Everything an item of any of the three kinds needs from the menu around it.
|
|
488
|
+
*
|
|
489
|
+
* One hook rather than three copies, because the three differ in their role and
|
|
490
|
+
* their state and in nothing else: the same id, the same roving tab stop, the
|
|
491
|
+
* same "a disabled item is announced and stepped over", and the same rule about
|
|
492
|
+
* when a press closes the tree.
|
|
493
|
+
*/
|
|
494
|
+
hook useMenuItem(
|
|
495
|
+
part: string,
|
|
496
|
+
disabled: boolean,
|
|
497
|
+
closeOnSelect: boolean,
|
|
498
|
+
onSelect: ((event: MenuSelect) => mixed) | void,
|
|
499
|
+
act: (() => void) | void,
|
|
500
|
+
): {|
|
|
501
|
+
readonly id: string,
|
|
502
|
+
readonly onClick: (event: MenuSelect) => void,
|
|
503
|
+
readonly onFocus: () => void,
|
|
504
|
+
readonly tabIndex: number,
|
|
505
|
+
|} {
|
|
506
|
+
const menu = useMenu(part);
|
|
507
|
+
const list = useContext(MenuListContext);
|
|
508
|
+
const id = useId();
|
|
509
|
+
const setActiveId = list?.setActiveId;
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
id,
|
|
513
|
+
onClick: (event: MenuSelect) => {
|
|
514
|
+
if (disabled) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
act?.();
|
|
518
|
+
onSelect?.(event);
|
|
519
|
+
// The caller's answer, read after they have had the event: a
|
|
520
|
+
// `preventDefault()` in `onSelect` is "I handled this, leave the menu
|
|
521
|
+
// open", which is the same sentence `composeHandlers` reads between a
|
|
522
|
+
// caller's handler and this package's.
|
|
523
|
+
if (closeOnSelect && !event.defaultPrevented) {
|
|
524
|
+
closeTree(menu);
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
onFocus: () => setActiveId?.(id),
|
|
528
|
+
tabIndex: list?.activeId === id ? 0 : -1,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
471
532
|
/**
|
|
472
533
|
* One command in the menu.
|
|
473
534
|
*
|
|
@@ -476,44 +537,190 @@ export component MenuBody(
|
|
|
476
537
|
* that the command exists and is unavailable, where a native `disabled` leaves
|
|
477
538
|
* a silent gap they cannot ask about. The arrow keys and typeahead step over it
|
|
478
539
|
* either way.
|
|
540
|
+
*
|
|
541
|
+
* `render` is what makes a menu of links possible, and a menu of links is the
|
|
542
|
+
* most ordinary menu there is:
|
|
543
|
+
*
|
|
544
|
+
* <Menu.Item render={(props) => <a href="/settings" {...props} />}>
|
|
545
|
+
* Settings
|
|
546
|
+
* </Menu.Item>
|
|
547
|
+
*
|
|
548
|
+
* The `<a>` keeps everything a link is for — the middle click, the context
|
|
549
|
+
* menu, "open in new tab", the status bar showing where it goes — and the item
|
|
550
|
+
* keeps the id, the roving tab stop, the role and the press that closes the
|
|
551
|
+
* tree. That combination is what shadcn's copy step is usually reached for, and
|
|
552
|
+
* what this package offers instead of it.
|
|
479
553
|
*/
|
|
480
554
|
export component MenuItem(
|
|
481
555
|
children: React.Node,
|
|
482
556
|
disabled?: boolean = false,
|
|
483
|
-
|
|
484
|
-
|
|
557
|
+
closeOnSelect?: boolean = true,
|
|
558
|
+
onSelect?: (event: MenuSelect) => mixed,
|
|
559
|
+
render?: RenderProp,
|
|
560
|
+
...rest: Rest
|
|
485
561
|
) {
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
562
|
+
const item = useMenuItem("Menu.Item", disabled, closeOnSelect, onSelect, undefined);
|
|
563
|
+
const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
|
|
564
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
565
|
+
children,
|
|
566
|
+
id: item.id,
|
|
567
|
+
onClick: composeHandlers(rest.onClick, item.onClick),
|
|
568
|
+
// The roving tab stop follows real focus rather than leading it, so a
|
|
569
|
+
// pointer that moves focus and a key that moves focus agree without the
|
|
570
|
+
// two of them having to be kept in step by hand.
|
|
571
|
+
onFocus: composeHandlers(rest.onFocus, item.onFocus),
|
|
572
|
+
role: "menuitem",
|
|
573
|
+
tabIndex: item.tabIndex,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
if (render != null) {
|
|
577
|
+
return render(props);
|
|
578
|
+
}
|
|
579
|
+
return <button {...props} type="button" />;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* An item that carries a state of its own: "show hidden files".
|
|
584
|
+
*
|
|
585
|
+
* `role="menuitemcheckbox"` with `aria-checked`, which is the role the arrow
|
|
586
|
+
* keys and the typeahead have always stepped across — `ITEM_SELECTOR` named it
|
|
587
|
+
* before there was a component that rendered it. What a caller could not
|
|
588
|
+
* hand-roll on `Menu.Item` is the rest: the controlled-and-uncontrolled
|
|
589
|
+
* contract `internal/controlled-state.js` states for everything here, and a
|
|
590
|
+
* press that does *not* dismiss the menu.
|
|
591
|
+
*
|
|
592
|
+
* There is no third state. `aria-checked="mixed"` belongs to a checkbox that
|
|
593
|
+
* summarises other checkboxes — `checkbox.js` has it, and a menu item is a
|
|
594
|
+
* command rather than a summary of a table's rows.
|
|
595
|
+
*/
|
|
596
|
+
export component MenuCheckboxItem(
|
|
597
|
+
children: React.Node,
|
|
598
|
+
checked?: boolean,
|
|
599
|
+
defaultChecked?: boolean = false,
|
|
600
|
+
onCheckedChange?: (checked: boolean) => void,
|
|
601
|
+
disabled?: boolean = false,
|
|
602
|
+
// A menu the reader is still ticking boxes in stays open; see the module
|
|
603
|
+
// header for why this default is the opposite of `Menu.Item`'s.
|
|
604
|
+
closeOnSelect?: boolean = false,
|
|
605
|
+
onSelect?: (event: MenuSelect) => mixed,
|
|
606
|
+
render?: RenderProp,
|
|
607
|
+
...rest: Rest
|
|
608
|
+
) {
|
|
609
|
+
const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
|
|
610
|
+
const toggle = useCallback(() => setOn(!on), [on, setOn]);
|
|
611
|
+
const item = useMenuItem("Menu.CheckboxItem", disabled, closeOnSelect, onSelect, toggle);
|
|
612
|
+
const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
|
|
613
|
+
"aria-checked": on ? "true" : "false",
|
|
614
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
615
|
+
children,
|
|
616
|
+
id: item.id,
|
|
617
|
+
onClick: composeHandlers(rest.onClick, item.onClick),
|
|
618
|
+
onFocus: composeHandlers(rest.onFocus, item.onFocus),
|
|
619
|
+
role: "menuitemcheckbox",
|
|
620
|
+
tabIndex: item.tabIndex,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
if (render != null) {
|
|
624
|
+
return render(props);
|
|
625
|
+
}
|
|
626
|
+
return <button {...props} type="button" />;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* A set of items of which exactly one is chosen.
|
|
631
|
+
*
|
|
632
|
+
* The *group* owns the value, which is what makes this a component rather than
|
|
633
|
+
* a convention: `aria-checked="true"` has to be on one item and `"false"` on
|
|
634
|
+
* the others, and a caller holding a value per item gets two checked ones the
|
|
635
|
+
* first time a render is skipped. `role="group"` is what ties them together for
|
|
636
|
+
* a reader — the items are `menuitemradio`, and a reader is told "2 of 3".
|
|
637
|
+
*
|
|
638
|
+
* `onValueChange` promises a `string` while the state is `string | null`, for
|
|
639
|
+
* the reason `radio-group.js` gives at greater length: "nothing chosen yet" is
|
|
640
|
+
* a state the group starts in and never an event it reports, because no gesture
|
|
641
|
+
* inside it unchooses an answer.
|
|
642
|
+
*/
|
|
643
|
+
export component MenuRadioGroup(
|
|
644
|
+
children: renders* (MenuRadioItem | MenuLabel | MenuSeparator),
|
|
645
|
+
defaultValue?: string | null = null,
|
|
646
|
+
value?: string | null,
|
|
647
|
+
onValueChange?: (value: string) => void,
|
|
648
|
+
render?: RenderProp,
|
|
649
|
+
...rest: Rest
|
|
650
|
+
) {
|
|
651
|
+
const base = useId();
|
|
652
|
+
const [labelled, setLabelled] = useState(false);
|
|
653
|
+
const report = useCallback(
|
|
654
|
+
(next: string | null) => {
|
|
655
|
+
if (next != null) {
|
|
656
|
+
onValueChange?.(next);
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
[onValueChange],
|
|
660
|
+
);
|
|
661
|
+
const [selected, select] = useControlled<string | null>(value, defaultValue, report);
|
|
662
|
+
|
|
663
|
+
const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
|
|
664
|
+
const radio = useMemo(
|
|
665
|
+
() => ({ value: selected, choose: (next: string) => select(next) }),
|
|
666
|
+
[selected, select],
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
const props = withProps(rest, {
|
|
670
|
+
"aria-labelledby": labelled ? group.labelId : undefined,
|
|
671
|
+
children,
|
|
672
|
+
role: "group",
|
|
673
|
+
});
|
|
491
674
|
|
|
492
675
|
return (
|
|
493
|
-
<
|
|
494
|
-
{
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
if (disabled) {
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
onSelect?.();
|
|
502
|
-
closeTree(menu);
|
|
503
|
-
})}
|
|
504
|
-
// The roving tab stop follows real focus rather than leading it, so a
|
|
505
|
-
// pointer that moves focus and a key that moves focus agree without the
|
|
506
|
-
// two of them having to be kept in step by hand.
|
|
507
|
-
onFocus={composeHandlers(rest.onFocus, () => setActiveId?.(id))}
|
|
508
|
-
role="menuitem"
|
|
509
|
-
tabIndex={list?.activeId === id ? 0 : -1}
|
|
510
|
-
type="button"
|
|
511
|
-
>
|
|
512
|
-
{children}
|
|
513
|
-
</button>
|
|
676
|
+
<MenuGroupContext.Provider value={group}>
|
|
677
|
+
<MenuRadioContext.Provider value={radio}>
|
|
678
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
679
|
+
</MenuRadioContext.Provider>
|
|
680
|
+
</MenuGroupContext.Provider>
|
|
514
681
|
);
|
|
515
682
|
}
|
|
516
683
|
|
|
684
|
+
/**
|
|
685
|
+
* One answer in a `Menu.RadioGroup`.
|
|
686
|
+
*
|
|
687
|
+
* Choosing it reports the group's new value and leaves the menu open, which is
|
|
688
|
+
* what a sort order or a zoom level in a native menu does; `closeOnSelect` is
|
|
689
|
+
* the way to say otherwise for a choice that ends the visit.
|
|
690
|
+
*/
|
|
691
|
+
export component MenuRadioItem(
|
|
692
|
+
children: React.Node,
|
|
693
|
+
value: string,
|
|
694
|
+
disabled?: boolean = false,
|
|
695
|
+
closeOnSelect?: boolean = false,
|
|
696
|
+
onSelect?: (event: MenuSelect) => mixed,
|
|
697
|
+
render?: RenderProp,
|
|
698
|
+
...rest: Rest
|
|
699
|
+
) {
|
|
700
|
+
const group = useContext(MenuRadioContext);
|
|
701
|
+
if (group == null) {
|
|
702
|
+
throw new Error("Menu.RadioItem must be rendered inside a Menu.RadioGroup");
|
|
703
|
+
}
|
|
704
|
+
const choose = group.choose;
|
|
705
|
+
const pick = useCallback(() => choose(value), [choose, value]);
|
|
706
|
+
const item = useMenuItem("Menu.RadioItem", disabled, closeOnSelect, onSelect, pick);
|
|
707
|
+
const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
|
|
708
|
+
"aria-checked": group.value === value ? "true" : "false",
|
|
709
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
710
|
+
children,
|
|
711
|
+
id: item.id,
|
|
712
|
+
onClick: composeHandlers(rest.onClick, item.onClick),
|
|
713
|
+
onFocus: composeHandlers(rest.onFocus, item.onFocus),
|
|
714
|
+
role: "menuitemradio",
|
|
715
|
+
tabIndex: item.tabIndex,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
if (render != null) {
|
|
719
|
+
return render(props);
|
|
720
|
+
}
|
|
721
|
+
return <button {...props} type="button" />;
|
|
722
|
+
}
|
|
723
|
+
|
|
517
724
|
/**
|
|
518
725
|
* The item that opens a submenu.
|
|
519
726
|
*
|
|
@@ -521,10 +728,9 @@ export component MenuItem(
|
|
|
521
728
|
* is why it reads the list context of the menu around it and the menu context
|
|
522
729
|
* of the one below it.
|
|
523
730
|
*/
|
|
524
|
-
export component MenuSubTrigger(children: React.Node, ...rest:
|
|
731
|
+
export component MenuSubTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
525
732
|
const menu = useMenu("Menu.SubTrigger");
|
|
526
733
|
const list = useContext(MenuListContext);
|
|
527
|
-
const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]);
|
|
528
734
|
const setActiveId = list?.setActiveId;
|
|
529
735
|
useTriggerRegistration(menu);
|
|
530
736
|
// The submenu's own trigger id, not a fresh one: the submenu names itself
|
|
@@ -532,39 +738,44 @@ export component MenuSubTrigger(children: React.Node, ...rest: { readonly [strin
|
|
|
532
738
|
const id = `${menu.base}-trigger`;
|
|
533
739
|
|
|
534
740
|
const open = () => {
|
|
535
|
-
|
|
741
|
+
// This is an instruction for the submenu body after the opening commit.
|
|
742
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
743
|
+
menu.pendingFocusRef.current = "first";
|
|
536
744
|
menu.setOpen(true);
|
|
537
745
|
};
|
|
538
746
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
)
|
|
747
|
+
const props = withProps(withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]), {
|
|
748
|
+
"aria-controls": menu.open ? `${menu.base}-body` : undefined,
|
|
749
|
+
"aria-expanded": menu.open ? "true" : "false",
|
|
750
|
+
"aria-haspopup": "menu",
|
|
751
|
+
children,
|
|
752
|
+
id,
|
|
753
|
+
onClick: composeHandlers(rest.onClick, open),
|
|
754
|
+
onFocus: composeHandlers(rest.onFocus, () => setActiveId?.(id)),
|
|
755
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
756
|
+
const trigger: $FlowFixMe = event.currentTarget;
|
|
757
|
+
if (event.key !== submenuKeys(directionOf(trigger)).open) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
event.preventDefault();
|
|
761
|
+
// The parent menu's own `ArrowRight` does nothing, but a menu three
|
|
762
|
+
// levels deep would otherwise see this key at every level.
|
|
763
|
+
event.stopPropagation();
|
|
764
|
+
open();
|
|
765
|
+
}),
|
|
766
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
767
|
+
// React calls callback refs during commit; the submenu body reads the trigger later.
|
|
768
|
+
// uf-lint-disable-next-line react-compiler/immutability
|
|
769
|
+
menu.triggerRef.current = element;
|
|
770
|
+
}),
|
|
771
|
+
role: "menuitem",
|
|
772
|
+
tabIndex: list?.activeId === id ? 0 : -1,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
if (render != null) {
|
|
776
|
+
return render(props);
|
|
777
|
+
}
|
|
778
|
+
return <button {...props} type="button" />;
|
|
568
779
|
}
|
|
569
780
|
|
|
570
781
|
/**
|
|
@@ -574,8 +785,12 @@ export component MenuSubTrigger(children: React.Node, ...rest: { readonly [strin
|
|
|
574
785
|
* moving through the menu is told the group changed. It is not focusable and
|
|
575
786
|
* the arrow keys pass straight over it.
|
|
576
787
|
*/
|
|
577
|
-
export component MenuSeparator(...rest:
|
|
578
|
-
|
|
788
|
+
export component MenuSeparator(render?: RenderProp, ...rest: Rest) {
|
|
789
|
+
const props = withProps(rest, { "aria-orientation": "horizontal", role: "separator" });
|
|
790
|
+
if (render != null) {
|
|
791
|
+
return render(props);
|
|
792
|
+
}
|
|
793
|
+
return <div {...props} />;
|
|
579
794
|
}
|
|
580
795
|
|
|
581
796
|
/**
|
|
@@ -586,29 +801,32 @@ export component MenuSeparator(...rest: { readonly [string]: mixed }) {
|
|
|
586
801
|
* that is not in the document makes a screen reader announce *nothing*, which
|
|
587
802
|
* is worse than an unnamed group.
|
|
588
803
|
*/
|
|
589
|
-
export component MenuGroup(children: React.Node, ...rest:
|
|
804
|
+
export component MenuGroup(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
590
805
|
const base = useId();
|
|
591
806
|
const [labelled, setLabelled] = useState(false);
|
|
592
807
|
|
|
593
808
|
const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
|
|
809
|
+
const props = withProps(rest, {
|
|
810
|
+
"aria-labelledby": labelled ? group.labelId : undefined,
|
|
811
|
+
children,
|
|
812
|
+
role: "group",
|
|
813
|
+
});
|
|
594
814
|
|
|
595
815
|
return (
|
|
596
816
|
<MenuGroupContext.Provider value={group}>
|
|
597
|
-
<div {...
|
|
598
|
-
{children}
|
|
599
|
-
</div>
|
|
817
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
600
818
|
</MenuGroupContext.Provider>
|
|
601
819
|
);
|
|
602
820
|
}
|
|
603
821
|
|
|
604
822
|
/**
|
|
605
|
-
* The heading of a `Menu.Group`.
|
|
823
|
+
* The heading of a `Menu.Group` or a `Menu.RadioGroup`.
|
|
606
824
|
*
|
|
607
825
|
* `role="presentation"` because the group already carries the name: leaving it
|
|
608
826
|
* as ordinary content would have a reader hear the heading once as the group's
|
|
609
827
|
* name and again as a stray line of text between the items.
|
|
610
828
|
*/
|
|
611
|
-
export component MenuLabel(children: React.Node, ...rest:
|
|
829
|
+
export component MenuLabel(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
612
830
|
const group = useContext(MenuGroupContext);
|
|
613
831
|
const register = group?.registerLabel;
|
|
614
832
|
|
|
@@ -620,9 +838,9 @@ export component MenuLabel(children: React.Node, ...rest: { readonly [string]: m
|
|
|
620
838
|
return () => register(false);
|
|
621
839
|
}, [register]);
|
|
622
840
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
841
|
+
const props = withProps(rest, { children, id: group?.labelId, role: "presentation" });
|
|
842
|
+
if (render != null) {
|
|
843
|
+
return render(props);
|
|
844
|
+
}
|
|
845
|
+
return <div {...props} />;
|
|
628
846
|
}
|