@uniflowed/ui 0.0.0-alpha.9 → 0.2.0

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