@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.
Files changed (58) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +550 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +264 -0
  9. package/collapsible.js +169 -0
  10. package/combobox.js +728 -0
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +523 -0
  14. package/drawer.js +490 -0
  15. package/field.js +387 -0
  16. package/hover-card.js +330 -0
  17. package/index.js +1699 -22
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2163 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/controlled-state.js +65 -0
  22. package/internal/date-grid.js +260 -0
  23. package/internal/disclosure.js +298 -0
  24. package/internal/focus.js +64 -0
  25. package/internal/form-value.js +83 -0
  26. package/internal/hover-intent.js +259 -0
  27. package/internal/menu-tree.js +228 -0
  28. package/internal/merge-props.js +285 -0
  29. package/internal/range.js +147 -0
  30. package/internal/roving-focus.js +430 -0
  31. package/menu.js +823 -0
  32. package/menubar.js +287 -0
  33. package/navigation-menu.js +251 -0
  34. package/package.json +9 -9
  35. package/pagination.js +209 -0
  36. package/popover.js +343 -0
  37. package/progress.js +91 -0
  38. package/radio-group.js +302 -0
  39. package/resizable.js +447 -0
  40. package/scroll-area.js +283 -0
  41. package/select.js +902 -0
  42. package/separator.js +97 -0
  43. package/sheet.js +189 -0
  44. package/sidebar.js +300 -0
  45. package/skeleton.js +159 -0
  46. package/slider.js +405 -0
  47. package/switch.js +81 -0
  48. package/table.js +502 -0
  49. package/tabs.js +289 -0
  50. package/toast.js +592 -0
  51. package/toggle-group.js +283 -0
  52. package/toggle.js +105 -0
  53. package/tooltip.js +400 -0
  54. package/internal/dialog.js +0 -236
  55. package/internal/field.js +0 -161
  56. package/internal/props.js +0 -78
  57. package/internal/switch.js +0 -122
  58. 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
+ }