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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/menu.js ADDED
@@ -0,0 +1,628 @@
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.
19
+ // * `Tab` closes the menu and carries on through the page, rather than
20
+ // walking the reader through thirty items they have already dismissed.
21
+ //
22
+ // # Focus moves; `aria-activedescendant` does not appear here
23
+ //
24
+ // A menu moves *real* DOM focus onto its items. That is what WAI-ARIA
25
+ // prescribes for this pattern, and it is why the items are buttons: activation,
26
+ // disabled semantics and the focus ring are the browser's rather than this
27
+ // component's. `aria-activedescendant` — a "virtual" focus that stays on the
28
+ // container — belongs to the pattern where focus cannot leave a text field,
29
+ // which is the combobox, and `combobox.js` uses it there.
30
+ //
31
+ // # Why hovering does not open a submenu
32
+ //
33
+ // It does nothing here on purpose. Opening on hover requires an intent
34
+ // heuristic — the "safe triangle" that lets the pointer travel diagonally
35
+ // across a sibling item to reach the submenu without it snapping shut — and a
36
+ // naive `onPointerEnter` that opens immediately is *worse* than no hover at
37
+ // all: it opens menus the reader was only passing over and closes the one they
38
+ // were aiming at. Keyboard and click open a submenu; a deliberate hover
39
+ // implementation is tracked work, not a line to be added carelessly.
40
+ //
41
+ // # Items are found in the document, not in a registry
42
+ //
43
+ // `internal/roving-focus.js` explains why. The short version is that mount
44
+ // order stops being document order the first time an item is conditional, and
45
+ // a submenu's items live *inside* its parent menu's element.
46
+
47
+ "use client";
48
+
49
+ import * as React from "@uniflowed/react";
50
+ import {
51
+ createContext,
52
+ useContext,
53
+ useEffect,
54
+ useId,
55
+ useMemo,
56
+ useRef,
57
+ useState,
58
+ } from "@uniflowed/react";
59
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
60
+
61
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
62
+ import {
63
+ indexOfActive,
64
+ isTypeaheadKey,
65
+ itemsOf,
66
+ movementFor,
67
+ moveTo,
68
+ useTypeahead,
69
+ } from "./internal/roving-focus.js";
70
+ import { useControlled } from "./internal/controlled-state.js";
71
+
72
+ /**
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.
115
+ *
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`.
119
+ */
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);
126
+
127
+ /** The id of a group's label, so `Menu.Group` only claims one that exists. */
128
+ type MenuGroupState = {|
129
+ readonly labelId: string,
130
+ readonly registerLabel: (present: boolean) => void,
131
+ |};
132
+
133
+ const MenuGroupContext: React.Context<MenuGroupState | null> = createContext(null);
134
+
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
+ }
170
+
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
+ }
183
+
184
+ /**
185
+ * A menu and its trigger.
186
+ *
187
+ * Renders no element of its own: a menu's trigger and its body are siblings in
188
+ * whatever layout the caller wrote, and a wrapper would put a `<div>` between
189
+ * them that the caller then has to style around.
190
+ */
191
+ export component MenuRoot(
192
+ children: React.Node,
193
+ defaultOpen?: boolean = false,
194
+ open?: boolean,
195
+ onOpenChange?: (open: boolean) => void,
196
+ ) {
197
+ return (
198
+ <MenuLevel defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open} parent={null}>
199
+ {children}
200
+ </MenuLevel>
201
+ );
202
+ }
203
+
204
+ /**
205
+ * A submenu: a menu whose trigger is an item of the menu around it.
206
+ *
207
+ * It is the same component as a root menu with one difference — it knows its
208
+ * parent — and that difference is what `ArrowLeft`, `Escape` and "choosing an
209
+ * item closes everything" are all defined in terms of.
210
+ */
211
+ export component MenuSub(
212
+ children: React.Node,
213
+ defaultOpen?: boolean = false,
214
+ open?: boolean,
215
+ onOpenChange?: (open: boolean) => void,
216
+ ) {
217
+ const parent = useContext(MenuContext);
218
+ if (parent == null) {
219
+ throw new Error("Menu.Sub must be rendered inside a Menu.Root");
220
+ }
221
+ return (
222
+ <MenuLevel defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open} parent={parent}>
223
+ {children}
224
+ </MenuLevel>
225
+ );
226
+ }
227
+
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
+ /** The button that opens the menu. */
260
+ export component MenuTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
261
+ const menu = useMenu("Menu.Trigger");
262
+ const passed = withoutComposed(rest, ["onClick", "onKeyDown", "ref"]);
263
+ useTriggerRegistration(menu);
264
+
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
+ );
299
+ }
300
+
301
+ /**
302
+ * The menu itself: the roving tab stop, the arrow keys, typeahead and Escape.
303
+ *
304
+ * The keys are handled here rather than on each item because every one of them
305
+ * is a question about the *set* — "the next item", "the item starting with r" —
306
+ * and only the container can answer it. Items still get their own `Enter` and
307
+ * `Space` from being buttons.
308
+ */
309
+ export component MenuBody(
310
+ children: renders* (MenuItem | MenuSeparator | MenuGroup | MenuSub),
311
+ ...rest: { readonly [string]: mixed }
312
+ ) {
313
+ const menu = useMenu("Menu.Body");
314
+ const bodyRef = useRef<HTMLElement | null>(null);
315
+ const [activeId, setActiveId] = useState<string | null>(null);
316
+ const typeahead = useTypeahead();
317
+
318
+ // Pulled out because they are stable for the life of the menu, which is what
319
+ // lets the effect below depend on `open` alone. Keyed on the context object
320
+ // it re-ran on every parent render and re-took focus each time, dragging the
321
+ // reader back to the first item while they were arrowing.
322
+ const triggerRef = menu.triggerRef;
323
+ const pendingFocus = menu.pendingFocus;
324
+ const isRoot = menu.parent == null;
325
+ const closeAll = useStableCallback(() => closeTree(menu));
326
+ // Set when the menu was dismissed by a press somewhere else, so the cleanup
327
+ // knows not to drag focus back to the trigger the reader just left.
328
+ const dismissed = useRef(false);
329
+
330
+ useEffect(() => {
331
+ const body = bodyRef.current;
332
+ if (!menu.open || body == null) {
333
+ return;
334
+ }
335
+ const document = body.ownerDocument;
336
+ const trigger = triggerRef.current;
337
+
338
+ const wanted = pendingFocus.current;
339
+ pendingFocus.current = null;
340
+ const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
341
+ const landing = moveTo(items, -1, wanted === "last" ? "last" : "first", false);
342
+ // The menu itself when it holds nothing focusable, so focus is inside it
343
+ // either way and Escape still reaches this component's handler.
344
+ (landing ?? body).focus();
345
+ if (landing != null) {
346
+ setActiveId(landing.id);
347
+ }
348
+
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
+ return () => {
370
+ if (isRoot) {
371
+ document.removeEventListener("pointerdown", onOutsidePress, true);
372
+ }
373
+ if (dismissed.current) {
374
+ dismissed.current = false;
375
+ return;
376
+ }
377
+ // Only when focus would otherwise be lost. Choosing an item in a submenu
378
+ // closes three menus at once, and each one restoring focus to its own
379
+ // trigger would leave it on a button that is itself being removed.
380
+ const active = document.activeElement;
381
+ if (active == null || active === document.body || body.contains(active)) {
382
+ trigger?.focus?.();
383
+ }
384
+ };
385
+ }, [menu.open, isRoot, triggerRef, pendingFocus, closeAll]);
386
+
387
+ const list = useMemo(() => ({ activeId, setActiveId }), [activeId]);
388
+
389
+ if (!menu.open) {
390
+ return null;
391
+ }
392
+
393
+ const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
394
+
395
+ return (
396
+ <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>
467
+ </MenuListContext.Provider>
468
+ );
469
+ }
470
+
471
+ /**
472
+ * One command in the menu.
473
+ *
474
+ * A disabled item is `aria-disabled` rather than `disabled`, so it stays in the
475
+ * accessibility tree: a reader is told "Delete, menu item, dimmed" and learns
476
+ * that the command exists and is unavailable, where a native `disabled` leaves
477
+ * a silent gap they cannot ask about. The arrow keys and typeahead step over it
478
+ * either way.
479
+ */
480
+ export component MenuItem(
481
+ children: React.Node,
482
+ disabled?: boolean = false,
483
+ onSelect?: () => mixed,
484
+ ...rest: { readonly [string]: mixed }
485
+ ) {
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;
491
+
492
+ 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>
514
+ );
515
+ }
516
+
517
+ /**
518
+ * The item that opens a submenu.
519
+ *
520
+ * It is a menu item of the *outer* menu and the trigger of the inner one, which
521
+ * is why it reads the list context of the menu around it and the menu context
522
+ * of the one below it.
523
+ */
524
+ export component MenuSubTrigger(children: React.Node, ...rest: { readonly [string]: mixed }) {
525
+ const menu = useMenu("Menu.SubTrigger");
526
+ const list = useContext(MenuListContext);
527
+ const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]);
528
+ const setActiveId = list?.setActiveId;
529
+ useTriggerRegistration(menu);
530
+ // The submenu's own trigger id, not a fresh one: the submenu names itself
531
+ // after it, and two ids for one element is how that link went stale.
532
+ const id = `${menu.base}-trigger`;
533
+
534
+ const open = () => {
535
+ menu.pendingFocus.current = "first";
536
+ menu.setOpen(true);
537
+ };
538
+
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
+ );
568
+ }
569
+
570
+ /**
571
+ * A rule between groups of items.
572
+ *
573
+ * `role="separator"` rather than an `<hr>` with a border, because a reader
574
+ * moving through the menu is told the group changed. It is not focusable and
575
+ * the arrow keys pass straight over it.
576
+ */
577
+ export component MenuSeparator(...rest: { readonly [string]: mixed }) {
578
+ return <div {...rest} aria-orientation="horizontal" role="separator" />;
579
+ }
580
+
581
+ /**
582
+ * A named group of items.
583
+ *
584
+ * The name has to reach the group through `aria-labelledby`, and only when a
585
+ * `Menu.Label` is actually rendered — an `aria-labelledby` pointing at an id
586
+ * that is not in the document makes a screen reader announce *nothing*, which
587
+ * is worse than an unnamed group.
588
+ */
589
+ export component MenuGroup(children: React.Node, ...rest: { readonly [string]: mixed }) {
590
+ const base = useId();
591
+ const [labelled, setLabelled] = useState(false);
592
+
593
+ const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
594
+
595
+ return (
596
+ <MenuGroupContext.Provider value={group}>
597
+ <div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
598
+ {children}
599
+ </div>
600
+ </MenuGroupContext.Provider>
601
+ );
602
+ }
603
+
604
+ /**
605
+ * The heading of a `Menu.Group`.
606
+ *
607
+ * `role="presentation"` because the group already carries the name: leaving it
608
+ * as ordinary content would have a reader hear the heading once as the group's
609
+ * name and again as a stray line of text between the items.
610
+ */
611
+ export component MenuLabel(children: React.Node, ...rest: { readonly [string]: mixed }) {
612
+ const group = useContext(MenuGroupContext);
613
+ const register = group?.registerLabel;
614
+
615
+ useEffect(() => {
616
+ if (register == null) {
617
+ return;
618
+ }
619
+ register(true);
620
+ return () => register(false);
621
+ }, [register]);
622
+
623
+ return (
624
+ <div {...rest} id={group?.labelId} role="presentation">
625
+ {children}
626
+ </div>
627
+ );
628
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/ui",
3
- "version": "0.0.0-alpha.2",
3
+ "version": "0.0.0-alpha.4",
4
4
  "description": "Headless, accessible React components whose composition Flow checks, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,18 +11,22 @@
11
11
  "directory": "packages/ui"
12
12
  },
13
13
  "exports": {
14
- "./dialog/types": "./dialog/types.js",
15
- "./form/types": "./form/types.js",
16
14
  ".": "./index.js",
17
- "./types/renders": "./types/renders.js"
15
+ "./checkbox": "./checkbox.js",
16
+ "./combobox": "./combobox.js",
17
+ "./dialog": "./dialog.js",
18
+ "./field": "./field.js",
19
+ "./menu": "./menu.js",
20
+ "./switch": "./switch.js",
21
+ "./tabs": "./tabs.js"
18
22
  },
19
23
  "files": [
20
- "index.js",
24
+ "*.js",
21
25
  "internal"
22
26
  ],
23
27
  "dependencies": {
24
- "@uniflowed/react": "0.0.0-alpha.2",
25
- "@uniflowed/validator": "0.0.0-alpha.2"
28
+ "@uniflowed/hooks": "0.0.0-alpha.4",
29
+ "@uniflowed/react": "0.0.0-alpha.4"
26
30
  },
27
31
  "peerDependencies": {
28
32
  "react": ">=19"