@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40

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 (52) 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 +547 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +216 -31
  9. package/collapsible.js +169 -0
  10. package/combobox.js +209 -40
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +229 -197
  14. package/drawer.js +490 -0
  15. package/field.js +257 -42
  16. package/hover-card.js +330 -0
  17. package/index.js +1548 -24
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2323 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/date-grid.js +260 -0
  22. package/internal/disclosure.js +298 -0
  23. package/internal/focus.js +64 -0
  24. package/internal/form-value.js +83 -0
  25. package/internal/hover-intent.js +259 -0
  26. package/internal/menu-tree.js +228 -0
  27. package/internal/merge-props.js +206 -7
  28. package/internal/range.js +147 -0
  29. package/internal/roving-focus.js +205 -11
  30. package/menu.js +521 -336
  31. package/menubar.js +288 -0
  32. package/navigation-menu.js +251 -0
  33. package/package.json +8 -12
  34. package/pagination.js +209 -0
  35. package/popover.js +344 -0
  36. package/progress.js +91 -0
  37. package/radio-group.js +302 -0
  38. package/resizable.js +447 -0
  39. package/scroll-area.js +283 -0
  40. package/select.js +888 -0
  41. package/separator.js +97 -0
  42. package/sheet.js +189 -0
  43. package/sidebar.js +313 -0
  44. package/skeleton.js +159 -0
  45. package/slider.js +405 -0
  46. package/switch.js +43 -34
  47. package/table.js +520 -0
  48. package/tabs.js +99 -96
  49. package/toast.js +592 -0
  50. package/toggle-group.js +282 -0
  51. package/toggle.js +105 -0
  52. package/tooltip.js +400 -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 { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
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
- * Anything that plays the part of a menu item, including the two checkable
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
- * Provided by `Menu.Body` rather than by the root, because a submenu is a
117
- * second list with a tab stop of its own: nesting the provider is what stops
118
- * the parent menu and the submenu from fighting over which item is `tabindex=0`.
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 MenuListState = {|
121
- readonly activeId: string | null,
122
- readonly setActiveId: (id: string | null) => void,
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
- hook useMenu(part: string): MenuState {
136
- const state = useContext(MenuContext);
137
- if (state == null) {
138
- throw new Error(`${part} must be rendered inside a Menu.Root`);
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,44 @@ 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: { readonly [string]: mixed }) {
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
+ menu.pendingFocus.current = end;
247
+ menu.setOpen(true);
248
+ }),
249
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
250
+ menu.triggerRef.current = element;
251
+ }),
252
+ });
264
253
 
265
- return (
266
- <button
267
- {...passed}
268
- // Named only while the menu is in the document, so a reader is never told
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
- );
254
+ if (render != null) {
255
+ return render(props);
256
+ }
257
+ return <button {...props} type="button" />;
299
258
  }
300
259
 
301
260
  /**
@@ -307,13 +266,30 @@ export component MenuTrigger(children: React.Node, ...rest: { readonly [string]:
307
266
  * `Space` from being buttons.
308
267
  */
309
268
  export component MenuBody(
310
- children: renders* (MenuItem | MenuSeparator | MenuGroup | MenuSub),
311
- ...rest: { readonly [string]: mixed }
269
+ children: renders* (
270
+ | MenuItem
271
+ | MenuCheckboxItem
272
+ | MenuRadioGroup
273
+ | MenuSeparator
274
+ | MenuGroup
275
+ | MenuSub
276
+ ),
277
+ align?: Align = "start",
278
+ alignOffset?: number = 0,
279
+ avoidCollisions?: boolean = true,
280
+ collisionPadding?: number = 0,
281
+ side?: LogicalSide,
282
+ sideOffset?: number = 0,
283
+ render?: RenderProp,
284
+ ...rest: Rest
312
285
  ) {
313
286
  const menu = useMenu("Menu.Body");
314
287
  const bodyRef = useRef<HTMLElement | null>(null);
315
288
  const [activeId, setActiveId] = useState<string | null>(null);
316
289
  const typeahead = useTypeahead();
290
+ // A point to open at, when whatever opened this menu was a pointer rather
291
+ // than a button. Null for every menu that hangs off a trigger.
292
+ const point = useContext(MenuAnchorContext);
317
293
 
318
294
  // Pulled out because they are stable for the life of the menu, which is what
319
295
  // lets the effect below depend on `open` alone. Keyed on the context object
@@ -323,6 +299,23 @@ export component MenuBody(
323
299
  const pendingFocus = menu.pendingFocus;
324
300
  const isRoot = menu.parent == null;
325
301
  const closeAll = useStableCallback(() => closeTree(menu));
302
+ // A root menu drops from its button; a submenu comes out of the side of the
303
+ // item that opened it, on the side the page reads towards. The default cannot
304
+ // be a parameter default because it is not a constant: it is the answer to
305
+ // "is this the outermost menu", which only this component knows.
306
+ const placement = side ?? (isRoot ? "bottom" : "inline-end");
307
+ const anchored = useAnchor({
308
+ align,
309
+ alignOffset,
310
+ anchorRect: point,
311
+ anchorRef: triggerRef,
312
+ avoidCollisions,
313
+ collisionPadding,
314
+ open: menu.open,
315
+ overlayRef: bodyRef,
316
+ side: placement,
317
+ sideOffset,
318
+ });
326
319
  // Set when the menu was dismissed by a press somewhere else, so the cleanup
327
320
  // knows not to drag focus back to the trigger the reader just left.
328
321
  const dismissed = useRef(false);
@@ -346,30 +339,7 @@ export component MenuBody(
346
339
  setActiveId(landing.id);
347
340
  }
348
341
 
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
342
  return () => {
370
- if (isRoot) {
371
- document.removeEventListener("pointerdown", onOutsidePress, true);
372
- }
373
343
  if (dismissed.current) {
374
344
  dismissed.current = false;
375
345
  return;
@@ -382,7 +352,18 @@ export component MenuBody(
382
352
  trigger?.focus?.();
383
353
  }
384
354
  };
385
- }, [menu.open, isRoot, triggerRef, pendingFocus, closeAll]);
355
+ }, [menu.open, triggerRef, pendingFocus]);
356
+
357
+ // Only the outermost menu listens. A submenu closes with the tree, and two
358
+ // of these would each answer the same press.
359
+ useInteractOutside({
360
+ isDisabled: !menu.open || !isRoot,
361
+ onInteractOutside: () => {
362
+ dismissed.current = true;
363
+ closeAll();
364
+ },
365
+ refs: [bodyRef, triggerRef],
366
+ });
386
367
 
387
368
  const list = useMemo(() => ({ activeId, setActiveId }), [activeId]);
388
369
 
@@ -390,84 +371,135 @@ export component MenuBody(
390
371
  return null;
391
372
  }
392
373
 
393
- const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
374
+ const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
375
+ "aria-labelledby": menu.triggered ? `${menu.base}-trigger` : undefined,
376
+ "aria-orientation": "vertical",
377
+ children,
378
+ "data-align": anchored.align,
379
+ "data-side": anchored.side,
380
+ id: `${menu.base}-body`,
381
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
382
+ const body: $FlowFixMe = event.currentTarget;
383
+ const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
384
+ const at = indexOfActive(items, body.ownerDocument?.activeElement);
385
+
386
+ if (event.key === "Escape") {
387
+ event.preventDefault();
388
+ // This menu, not the one behind it and not the dialog around it.
389
+ // A submenu is a DOM descendant of its parent menu, so without this
390
+ // one Escape closed the whole tree at once.
391
+ event.stopPropagation();
392
+ menu.setOpen(false);
393
+ return;
394
+ }
395
+
396
+ if (event.key === "Tab") {
397
+ // Not prevented: the browser should carry on to the next control,
398
+ // which is what makes Tab a way *past* a menu rather than a way
399
+ // through its thirty items.
400
+ event.stopPropagation();
401
+ closeAll();
402
+ return;
403
+ }
404
+
405
+ // Asked once, here, and used for both questions below: which key
406
+ // closes this submenu, and — for a menu a caller has laid out
407
+ // horizontally one day — which way the arrows run.
408
+ const direction = directionOf(body);
409
+
410
+ if (!isRoot && event.key === submenuKeys(direction).close) {
411
+ event.preventDefault();
412
+ event.stopPropagation();
413
+ menu.setOpen(false);
414
+ return;
415
+ }
416
+
417
+ const movement = movementFor(event.key, "vertical", direction);
418
+ if (movement != null) {
419
+ // Before moving, or the arrow also scrolls the page under the item
420
+ // that just took focus.
421
+ event.preventDefault();
422
+ event.stopPropagation();
423
+ const next = moveTo(items, at, movement, true);
424
+ if (next != null) {
425
+ next.focus();
426
+ setActiveId(next.id);
427
+ }
428
+ return;
429
+ }
430
+
431
+ if (isTypeaheadKey(event)) {
432
+ const next = typeahead(items, at, event.key);
433
+ if (next != null) {
434
+ event.preventDefault();
435
+ event.stopPropagation();
436
+ next.focus();
437
+ setActiveId(next.id);
438
+ }
439
+ }
440
+ }),
441
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
442
+ bodyRef.current = element;
443
+ }),
444
+ role: "menu",
445
+ // So the menu can hold focus itself when it is empty, and so a press on
446
+ // its padding does not send focus to `<body>`.
447
+ tabIndex: -1,
448
+ });
394
449
 
395
450
  return (
396
451
  <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>
452
+ {render == null ? <div {...props} /> : render(props)}
467
453
  </MenuListContext.Provider>
468
454
  );
469
455
  }
470
456
 
457
+ /**
458
+ * Everything an item of any of the three kinds needs from the menu around it.
459
+ *
460
+ * One hook rather than three copies, because the three differ in their role and
461
+ * their state and in nothing else: the same id, the same roving tab stop, the
462
+ * same "a disabled item is announced and stepped over", and the same rule about
463
+ * when a press closes the tree.
464
+ */
465
+ hook useMenuItem(
466
+ part: string,
467
+ disabled: boolean,
468
+ closeOnSelect: boolean,
469
+ onSelect: ((event: MenuSelect) => mixed) | void,
470
+ act: (() => void) | void,
471
+ ): {|
472
+ readonly id: string,
473
+ readonly onClick: (event: MenuSelect) => void,
474
+ readonly onFocus: () => void,
475
+ readonly tabIndex: number,
476
+ |} {
477
+ const menu = useMenu(part);
478
+ const list = useContext(MenuListContext);
479
+ const id = useId();
480
+ const setActiveId = list?.setActiveId;
481
+
482
+ return {
483
+ id,
484
+ onClick: (event: MenuSelect) => {
485
+ if (disabled) {
486
+ return;
487
+ }
488
+ act?.();
489
+ onSelect?.(event);
490
+ // The caller's answer, read after they have had the event: a
491
+ // `preventDefault()` in `onSelect` is "I handled this, leave the menu
492
+ // open", which is the same sentence `composeHandlers` reads between a
493
+ // caller's handler and this package's.
494
+ if (closeOnSelect && !event.defaultPrevented) {
495
+ closeTree(menu);
496
+ }
497
+ },
498
+ onFocus: () => setActiveId?.(id),
499
+ tabIndex: list?.activeId === id ? 0 : -1,
500
+ };
501
+ }
502
+
471
503
  /**
472
504
  * One command in the menu.
473
505
  *
@@ -476,44 +508,190 @@ export component MenuBody(
476
508
  * that the command exists and is unavailable, where a native `disabled` leaves
477
509
  * a silent gap they cannot ask about. The arrow keys and typeahead step over it
478
510
  * either way.
511
+ *
512
+ * `render` is what makes a menu of links possible, and a menu of links is the
513
+ * most ordinary menu there is:
514
+ *
515
+ * <Menu.Item render={(props) => <a href="/settings" {...props} />}>
516
+ * Settings
517
+ * </Menu.Item>
518
+ *
519
+ * The `<a>` keeps everything a link is for — the middle click, the context
520
+ * menu, "open in new tab", the status bar showing where it goes — and the item
521
+ * keeps the id, the roving tab stop, the role and the press that closes the
522
+ * tree. That combination is what shadcn's copy step is usually reached for, and
523
+ * what this package offers instead of it.
479
524
  */
480
525
  export component MenuItem(
481
526
  children: React.Node,
482
527
  disabled?: boolean = false,
483
- onSelect?: () => mixed,
484
- ...rest: { readonly [string]: mixed }
528
+ closeOnSelect?: boolean = true,
529
+ onSelect?: (event: MenuSelect) => mixed,
530
+ render?: RenderProp,
531
+ ...rest: Rest
485
532
  ) {
486
- const menu = useMenu("Menu.Item");
487
- const list = useContext(MenuListContext);
488
- const id = useId();
489
- const passed = withoutComposed(rest, ["onClick", "onFocus"]);
490
- const setActiveId = list?.setActiveId;
533
+ const item = useMenuItem("Menu.Item", disabled, closeOnSelect, onSelect, undefined);
534
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
535
+ "aria-disabled": disabled ? "true" : undefined,
536
+ children,
537
+ id: item.id,
538
+ onClick: composeHandlers(rest.onClick, item.onClick),
539
+ // The roving tab stop follows real focus rather than leading it, so a
540
+ // pointer that moves focus and a key that moves focus agree without the
541
+ // two of them having to be kept in step by hand.
542
+ onFocus: composeHandlers(rest.onFocus, item.onFocus),
543
+ role: "menuitem",
544
+ tabIndex: item.tabIndex,
545
+ });
546
+
547
+ if (render != null) {
548
+ return render(props);
549
+ }
550
+ return <button {...props} type="button" />;
551
+ }
552
+
553
+ /**
554
+ * An item that carries a state of its own: "show hidden files".
555
+ *
556
+ * `role="menuitemcheckbox"` with `aria-checked`, which is the role the arrow
557
+ * keys and the typeahead have always stepped across — `ITEM_SELECTOR` named it
558
+ * before there was a component that rendered it. What a caller could not
559
+ * hand-roll on `Menu.Item` is the rest: the controlled-and-uncontrolled
560
+ * contract `internal/controlled-state.js` states for everything here, and a
561
+ * press that does *not* dismiss the menu.
562
+ *
563
+ * There is no third state. `aria-checked="mixed"` belongs to a checkbox that
564
+ * summarises other checkboxes — `checkbox.js` has it, and a menu item is a
565
+ * command rather than a summary of a table's rows.
566
+ */
567
+ export component MenuCheckboxItem(
568
+ children: React.Node,
569
+ checked?: boolean,
570
+ defaultChecked?: boolean = false,
571
+ onCheckedChange?: (checked: boolean) => void,
572
+ disabled?: boolean = false,
573
+ // A menu the reader is still ticking boxes in stays open; see the module
574
+ // header for why this default is the opposite of `Menu.Item`'s.
575
+ closeOnSelect?: boolean = false,
576
+ onSelect?: (event: MenuSelect) => mixed,
577
+ render?: RenderProp,
578
+ ...rest: Rest
579
+ ) {
580
+ const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
581
+ const toggle = useCallback(() => setOn(!on), [on, setOn]);
582
+ const item = useMenuItem("Menu.CheckboxItem", disabled, closeOnSelect, onSelect, toggle);
583
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
584
+ "aria-checked": on ? "true" : "false",
585
+ "aria-disabled": disabled ? "true" : undefined,
586
+ children,
587
+ id: item.id,
588
+ onClick: composeHandlers(rest.onClick, item.onClick),
589
+ onFocus: composeHandlers(rest.onFocus, item.onFocus),
590
+ role: "menuitemcheckbox",
591
+ tabIndex: item.tabIndex,
592
+ });
593
+
594
+ if (render != null) {
595
+ return render(props);
596
+ }
597
+ return <button {...props} type="button" />;
598
+ }
599
+
600
+ /**
601
+ * A set of items of which exactly one is chosen.
602
+ *
603
+ * The *group* owns the value, which is what makes this a component rather than
604
+ * a convention: `aria-checked="true"` has to be on one item and `"false"` on
605
+ * the others, and a caller holding a value per item gets two checked ones the
606
+ * first time a render is skipped. `role="group"` is what ties them together for
607
+ * a reader — the items are `menuitemradio`, and a reader is told "2 of 3".
608
+ *
609
+ * `onValueChange` promises a `string` while the state is `string | null`, for
610
+ * the reason `radio-group.js` gives at greater length: "nothing chosen yet" is
611
+ * a state the group starts in and never an event it reports, because no gesture
612
+ * inside it unchooses an answer.
613
+ */
614
+ export component MenuRadioGroup(
615
+ children: renders* (MenuRadioItem | MenuLabel | MenuSeparator),
616
+ defaultValue?: string | null = null,
617
+ value?: string | null,
618
+ onValueChange?: (value: string) => void,
619
+ render?: RenderProp,
620
+ ...rest: Rest
621
+ ) {
622
+ const base = useId();
623
+ const [labelled, setLabelled] = useState(false);
624
+ const report = useCallback(
625
+ (next: string | null) => {
626
+ if (next != null) {
627
+ onValueChange?.(next);
628
+ }
629
+ },
630
+ [onValueChange],
631
+ );
632
+ const [selected, select] = useControlled<string | null>(value, defaultValue, report);
633
+
634
+ const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
635
+ const radio = useMemo(
636
+ () => ({ value: selected, choose: (next: string) => select(next) }),
637
+ [selected, select],
638
+ );
639
+
640
+ const props = withProps(rest, {
641
+ "aria-labelledby": labelled ? group.labelId : undefined,
642
+ children,
643
+ role: "group",
644
+ });
491
645
 
492
646
  return (
493
- <button
494
- {...passed}
495
- aria-disabled={disabled ? "true" : undefined}
496
- id={id}
497
- onClick={composeHandlers(rest.onClick, () => {
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>
647
+ <MenuGroupContext.Provider value={group}>
648
+ <MenuRadioContext.Provider value={radio}>
649
+ {render == null ? <div {...props} /> : render(props)}
650
+ </MenuRadioContext.Provider>
651
+ </MenuGroupContext.Provider>
514
652
  );
515
653
  }
516
654
 
655
+ /**
656
+ * One answer in a `Menu.RadioGroup`.
657
+ *
658
+ * Choosing it reports the group's new value and leaves the menu open, which is
659
+ * what a sort order or a zoom level in a native menu does; `closeOnSelect` is
660
+ * the way to say otherwise for a choice that ends the visit.
661
+ */
662
+ export component MenuRadioItem(
663
+ children: React.Node,
664
+ value: string,
665
+ disabled?: boolean = false,
666
+ closeOnSelect?: boolean = false,
667
+ onSelect?: (event: MenuSelect) => mixed,
668
+ render?: RenderProp,
669
+ ...rest: Rest
670
+ ) {
671
+ const group = useContext(MenuRadioContext);
672
+ if (group == null) {
673
+ throw new Error("Menu.RadioItem must be rendered inside a Menu.RadioGroup");
674
+ }
675
+ const choose = group.choose;
676
+ const pick = useCallback(() => choose(value), [choose, value]);
677
+ const item = useMenuItem("Menu.RadioItem", disabled, closeOnSelect, onSelect, pick);
678
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
679
+ "aria-checked": group.value === value ? "true" : "false",
680
+ "aria-disabled": disabled ? "true" : undefined,
681
+ children,
682
+ id: item.id,
683
+ onClick: composeHandlers(rest.onClick, item.onClick),
684
+ onFocus: composeHandlers(rest.onFocus, item.onFocus),
685
+ role: "menuitemradio",
686
+ tabIndex: item.tabIndex,
687
+ });
688
+
689
+ if (render != null) {
690
+ return render(props);
691
+ }
692
+ return <button {...props} type="button" />;
693
+ }
694
+
517
695
  /**
518
696
  * The item that opens a submenu.
519
697
  *
@@ -521,10 +699,9 @@ export component MenuItem(
521
699
  * is why it reads the list context of the menu around it and the menu context
522
700
  * of the one below it.
523
701
  */
524
- export component MenuSubTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
702
+ export component MenuSubTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
525
703
  const menu = useMenu("Menu.SubTrigger");
526
704
  const list = useContext(MenuListContext);
527
- const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]);
528
705
  const setActiveId = list?.setActiveId;
529
706
  useTriggerRegistration(menu);
530
707
  // The submenu's own trigger id, not a fresh one: the submenu names itself
@@ -536,35 +713,36 @@ export component MenuSubTrigger(children: React.Node, ...rest: { readonly [strin
536
713
  menu.setOpen(true);
537
714
  };
538
715
 
539
- return (
540
- <button
541
- {...passed}
542
- aria-controls={menu.open ? `${menu.base}-body` : undefined}
543
- aria-expanded={menu.open ? "true" : "false"}
544
- aria-haspopup="menu"
545
- id={id}
546
- onClick={composeHandlers(rest.onClick, open)}
547
- onFocus={composeHandlers(rest.onFocus, () => setActiveId?.(id))}
548
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
549
- if (event.key !== "ArrowRight") {
550
- return;
551
- }
552
- event.preventDefault();
553
- // The parent menu's own `ArrowRight` does nothing, but a menu three
554
- // levels deep would otherwise see this key at every level.
555
- event.stopPropagation();
556
- open();
557
- })}
558
- ref={composeRefs(rest.ref, (element) => {
559
- menu.triggerRef.current = element;
560
- })}
561
- role="menuitem"
562
- tabIndex={list?.activeId === id ? 0 : -1}
563
- type="button"
564
- >
565
- {children}
566
- </button>
567
- );
716
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]), {
717
+ "aria-controls": menu.open ? `${menu.base}-body` : undefined,
718
+ "aria-expanded": menu.open ? "true" : "false",
719
+ "aria-haspopup": "menu",
720
+ children,
721
+ id,
722
+ onClick: composeHandlers(rest.onClick, open),
723
+ onFocus: composeHandlers(rest.onFocus, () => setActiveId?.(id)),
724
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
725
+ const trigger: $FlowFixMe = event.currentTarget;
726
+ if (event.key !== submenuKeys(directionOf(trigger)).open) {
727
+ return;
728
+ }
729
+ event.preventDefault();
730
+ // The parent menu's own `ArrowRight` does nothing, but a menu three
731
+ // levels deep would otherwise see this key at every level.
732
+ event.stopPropagation();
733
+ open();
734
+ }),
735
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
736
+ menu.triggerRef.current = element;
737
+ }),
738
+ role: "menuitem",
739
+ tabIndex: list?.activeId === id ? 0 : -1,
740
+ });
741
+
742
+ if (render != null) {
743
+ return render(props);
744
+ }
745
+ return <button {...props} type="button" />;
568
746
  }
569
747
 
570
748
  /**
@@ -574,8 +752,12 @@ export component MenuSubTrigger(children: React.Node, ...rest: { readonly [strin
574
752
  * moving through the menu is told the group changed. It is not focusable and
575
753
  * the arrow keys pass straight over it.
576
754
  */
577
- export component MenuSeparator(...rest: { readonly [string]: mixed }) {
578
- return <div {...rest} aria-orientation="horizontal" role="separator" />;
755
+ export component MenuSeparator(render?: RenderProp, ...rest: Rest) {
756
+ const props = withProps(rest, { "aria-orientation": "horizontal", role: "separator" });
757
+ if (render != null) {
758
+ return render(props);
759
+ }
760
+ return <div {...props} />;
579
761
  }
580
762
 
581
763
  /**
@@ -586,29 +768,32 @@ export component MenuSeparator(...rest: { readonly [string]: mixed }) {
586
768
  * that is not in the document makes a screen reader announce *nothing*, which
587
769
  * is worse than an unnamed group.
588
770
  */
589
- export component MenuGroup(children: React.Node, ...rest: { readonly [string]: mixed }) {
771
+ export component MenuGroup(children: React.Node, render?: RenderProp, ...rest: Rest) {
590
772
  const base = useId();
591
773
  const [labelled, setLabelled] = useState(false);
592
774
 
593
775
  const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
776
+ const props = withProps(rest, {
777
+ "aria-labelledby": labelled ? group.labelId : undefined,
778
+ children,
779
+ role: "group",
780
+ });
594
781
 
595
782
  return (
596
783
  <MenuGroupContext.Provider value={group}>
597
- <div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
598
- {children}
599
- </div>
784
+ {render == null ? <div {...props} /> : render(props)}
600
785
  </MenuGroupContext.Provider>
601
786
  );
602
787
  }
603
788
 
604
789
  /**
605
- * The heading of a `Menu.Group`.
790
+ * The heading of a `Menu.Group` or a `Menu.RadioGroup`.
606
791
  *
607
792
  * `role="presentation"` because the group already carries the name: leaving it
608
793
  * as ordinary content would have a reader hear the heading once as the group's
609
794
  * name and again as a stray line of text between the items.
610
795
  */
611
- export component MenuLabel(children: React.Node, ...rest: { readonly [string]: mixed }) {
796
+ export component MenuLabel(children: React.Node, render?: RenderProp, ...rest: Rest) {
612
797
  const group = useContext(MenuGroupContext);
613
798
  const register = group?.registerLabel;
614
799
 
@@ -620,9 +805,9 @@ export component MenuLabel(children: React.Node, ...rest: { readonly [string]: m
620
805
  return () => register(false);
621
806
  }, [register]);
622
807
 
623
- return (
624
- <div {...rest} id={group?.labelId} role="presentation">
625
- {children}
626
- </div>
627
- );
808
+ const props = withProps(rest, { children, id: group?.labelId, role: "presentation" });
809
+ if (render != null) {
810
+ return render(props);
811
+ }
812
+ return <div {...props} />;
628
813
  }