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