@uniflowed/ui 0.0.0-alpha.18 → 0.0.0-alpha.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/menu.js CHANGED
@@ -108,8 +108,13 @@ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
108
108
 
109
109
  import type { Align, LogicalSide } from "./internal/anchor.js";
110
110
  import { useAnchor } from "./internal/anchor.js";
111
- import type { Rest } from "./internal/merge-props.js";
112
- import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
111
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
112
+ import {
113
+ composeHandlers,
114
+ composeRefs,
115
+ withProps,
116
+ withoutComposed,
117
+ } from "./internal/merge-props.js";
113
118
  import {
114
119
  directionOf,
115
120
  indexOfActive,
@@ -211,45 +216,43 @@ export component MenuSub(
211
216
  }
212
217
 
213
218
  /** The button that opens the menu. */
214
- export component MenuTrigger(children: React.Node, ...rest: Rest) {
219
+ export component MenuTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
215
220
  const menu = useMenu("Menu.Trigger");
216
- const passed = withoutComposed(rest, ["onClick", "onKeyDown", "ref"]);
217
221
  useTriggerRegistration(menu);
222
+ const props = withProps(withoutComposed(rest, ["onClick", "onKeyDown", "ref"]), {
223
+ // Named only while the menu is in the document, so a reader is never told
224
+ // to go somewhere that is not there.
225
+ "aria-controls": menu.open ? `${menu.base}-body` : undefined,
226
+ "aria-expanded": menu.open ? "true" : "false",
227
+ "aria-haspopup": "menu",
228
+ children,
229
+ id: `${menu.base}-trigger`,
230
+ onClick: composeHandlers(rest.onClick, () => menu.setOpen(!menu.open)),
231
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
232
+ // `ArrowUp` opening onto the *last* item is the behaviour that makes a
233
+ // long menu usable: the last entry is usually the destructive one, and
234
+ // reaching it should not mean arrowing past everything else.
235
+ const end = match (event.key) {
236
+ "ArrowDown" => "first",
237
+ "ArrowUp" => "last",
238
+ _ => null,
239
+ };
240
+ if (end == null) {
241
+ return;
242
+ }
243
+ event.preventDefault();
244
+ menu.pendingFocus.current = end;
245
+ menu.setOpen(true);
246
+ }),
247
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
248
+ menu.triggerRef.current = element;
249
+ }),
250
+ });
218
251
 
219
- return (
220
- <button
221
- {...passed}
222
- // Named only while the menu is in the document, so a reader is never told
223
- // to go somewhere that is not there.
224
- aria-controls={menu.open ? `${menu.base}-body` : undefined}
225
- aria-expanded={menu.open ? "true" : "false"}
226
- aria-haspopup="menu"
227
- id={`${menu.base}-trigger`}
228
- onClick={composeHandlers(rest.onClick, () => menu.setOpen(!menu.open))}
229
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
230
- // `ArrowUp` opening onto the *last* item is the behaviour that makes a
231
- // long menu usable: the last entry is usually the destructive one, and
232
- // reaching it should not mean arrowing past everything else.
233
- const end = match (event.key) {
234
- "ArrowDown" => "first",
235
- "ArrowUp" => "last",
236
- _ => null,
237
- };
238
- if (end == null) {
239
- return;
240
- }
241
- event.preventDefault();
242
- menu.pendingFocus.current = end;
243
- menu.setOpen(true);
244
- })}
245
- ref={composeRefs(rest.ref, (element) => {
246
- menu.triggerRef.current = element;
247
- })}
248
- type="button"
249
- >
250
- {children}
251
- </button>
252
- );
252
+ if (render != null) {
253
+ return render(props);
254
+ }
255
+ return <button {...props} type="button" />;
253
256
  }
254
257
 
255
258
  /**
@@ -275,6 +278,7 @@ export component MenuBody(
275
278
  collisionPadding?: number = 0,
276
279
  side?: LogicalSide,
277
280
  sideOffset?: number = 0,
281
+ render?: RenderProp,
278
282
  ...rest: Rest
279
283
  ) {
280
284
  const menu = useMenu("Menu.Body");
@@ -377,87 +381,85 @@ export component MenuBody(
377
381
  return null;
378
382
  }
379
383
 
380
- const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
384
+ const props = withProps(withoutComposed(rest, ["onKeyDown", "ref"]), {
385
+ "aria-labelledby": menu.triggered ? `${menu.base}-trigger` : undefined,
386
+ "aria-orientation": "vertical",
387
+ children,
388
+ "data-align": anchored.align,
389
+ "data-side": anchored.side,
390
+ id: `${menu.base}-body`,
391
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
392
+ const body: $FlowFixMe = event.currentTarget;
393
+ const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
394
+ const at = indexOfActive(items, body.ownerDocument?.activeElement);
395
+
396
+ if (event.key === "Escape") {
397
+ event.preventDefault();
398
+ // This menu, not the one behind it and not the dialog around it.
399
+ // A submenu is a DOM descendant of its parent menu, so without this
400
+ // one Escape closed the whole tree at once.
401
+ event.stopPropagation();
402
+ menu.setOpen(false);
403
+ return;
404
+ }
405
+
406
+ if (event.key === "Tab") {
407
+ // Not prevented: the browser should carry on to the next control,
408
+ // which is what makes Tab a way *past* a menu rather than a way
409
+ // through its thirty items.
410
+ event.stopPropagation();
411
+ closeAll();
412
+ return;
413
+ }
414
+
415
+ // Asked once, here, and used for both questions below: which key
416
+ // closes this submenu, and — for a menu a caller has laid out
417
+ // horizontally one day — which way the arrows run.
418
+ const direction = directionOf(body);
419
+
420
+ if (!isRoot && event.key === submenuKeys(direction).close) {
421
+ event.preventDefault();
422
+ event.stopPropagation();
423
+ menu.setOpen(false);
424
+ return;
425
+ }
426
+
427
+ const movement = movementFor(event.key, "vertical", direction);
428
+ if (movement != null) {
429
+ // Before moving, or the arrow also scrolls the page under the item
430
+ // that just took focus.
431
+ event.preventDefault();
432
+ event.stopPropagation();
433
+ const next = moveTo(items, at, movement, true);
434
+ if (next != null) {
435
+ next.focus();
436
+ setActiveId(next.id);
437
+ }
438
+ return;
439
+ }
440
+
441
+ if (isTypeaheadKey(event)) {
442
+ const next = typeahead(items, at, event.key);
443
+ if (next != null) {
444
+ event.preventDefault();
445
+ event.stopPropagation();
446
+ next.focus();
447
+ setActiveId(next.id);
448
+ }
449
+ }
450
+ }),
451
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
452
+ bodyRef.current = element;
453
+ }),
454
+ role: "menu",
455
+ // So the menu can hold focus itself when it is empty, and so a press on
456
+ // its padding does not send focus to `<body>`.
457
+ tabIndex: -1,
458
+ });
381
459
 
382
460
  return (
383
461
  <MenuListContext.Provider value={list}>
384
- <div
385
- {...passed}
386
- aria-labelledby={menu.triggered ? `${menu.base}-trigger` : undefined}
387
- aria-orientation="vertical"
388
- data-align={anchored.align}
389
- data-side={anchored.side}
390
- id={`${menu.base}-body`}
391
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
392
- const body: $FlowFixMe = event.currentTarget;
393
- const items = itemsOf(body, ITEM_SELECTOR, MENU_SELECTOR);
394
- const at = indexOfActive(items, body.ownerDocument?.activeElement);
395
-
396
- if (event.key === "Escape") {
397
- event.preventDefault();
398
- // This menu, not the one behind it and not the dialog around it.
399
- // A submenu is a DOM descendant of its parent menu, so without this
400
- // one Escape closed the whole tree at once.
401
- event.stopPropagation();
402
- menu.setOpen(false);
403
- return;
404
- }
405
-
406
- if (event.key === "Tab") {
407
- // Not prevented: the browser should carry on to the next control,
408
- // which is what makes Tab a way *past* a menu rather than a way
409
- // through its thirty items.
410
- event.stopPropagation();
411
- closeAll();
412
- return;
413
- }
414
-
415
- // Asked once, here, and used for both questions below: which key
416
- // closes this submenu, and — for a menu a caller has laid out
417
- // horizontally one day — which way the arrows run.
418
- const direction = directionOf(body);
419
-
420
- if (!isRoot && event.key === submenuKeys(direction).close) {
421
- event.preventDefault();
422
- event.stopPropagation();
423
- menu.setOpen(false);
424
- return;
425
- }
426
-
427
- const movement = movementFor(event.key, "vertical", direction);
428
- if (movement != null) {
429
- // Before moving, or the arrow also scrolls the page under the item
430
- // that just took focus.
431
- event.preventDefault();
432
- event.stopPropagation();
433
- const next = moveTo(items, at, movement, true);
434
- if (next != null) {
435
- next.focus();
436
- setActiveId(next.id);
437
- }
438
- return;
439
- }
440
-
441
- if (isTypeaheadKey(event)) {
442
- const next = typeahead(items, at, event.key);
443
- if (next != null) {
444
- event.preventDefault();
445
- event.stopPropagation();
446
- next.focus();
447
- setActiveId(next.id);
448
- }
449
- }
450
- })}
451
- ref={composeRefs(rest.ref, (element) => {
452
- bodyRef.current = element;
453
- })}
454
- role="menu"
455
- // So the menu can hold focus itself when it is empty, and so a press on
456
- // its padding does not send focus to `<body>`.
457
- tabIndex={-1}
458
- >
459
- {children}
460
- </div>
462
+ {render == null ? <div {...props} /> : render(props)}
461
463
  </MenuListContext.Provider>
462
464
  );
463
465
  }
@@ -516,34 +518,46 @@ hook useMenuItem(
516
518
  * that the command exists and is unavailable, where a native `disabled` leaves
517
519
  * a silent gap they cannot ask about. The arrow keys and typeahead step over it
518
520
  * either way.
521
+ *
522
+ * `render` is what makes a menu of links possible, and a menu of links is the
523
+ * most ordinary menu there is:
524
+ *
525
+ * <Menu.Item render={(props) => <a href="/settings" {...props} />}>
526
+ * Settings
527
+ * </Menu.Item>
528
+ *
529
+ * The `<a>` keeps everything a link is for — the middle click, the context
530
+ * menu, "open in new tab", the status bar showing where it goes — and the item
531
+ * keeps the id, the roving tab stop, the role and the press that closes the
532
+ * tree. That combination is what shadcn's copy step is usually reached for, and
533
+ * what this package offers instead of it.
519
534
  */
520
535
  export component MenuItem(
521
536
  children: React.Node,
522
537
  disabled?: boolean = false,
523
538
  closeOnSelect?: boolean = true,
524
539
  onSelect?: (event: MenuSelect) => mixed,
540
+ render?: RenderProp,
525
541
  ...rest: Rest
526
542
  ) {
527
543
  const item = useMenuItem("Menu.Item", disabled, closeOnSelect, onSelect, undefined);
528
- const passed = withoutComposed(rest, ["onClick", "onFocus"]);
544
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
545
+ "aria-disabled": disabled ? "true" : undefined,
546
+ children,
547
+ id: item.id,
548
+ onClick: composeHandlers(rest.onClick, item.onClick),
549
+ // The roving tab stop follows real focus rather than leading it, so a
550
+ // pointer that moves focus and a key that moves focus agree without the
551
+ // two of them having to be kept in step by hand.
552
+ onFocus: composeHandlers(rest.onFocus, item.onFocus),
553
+ role: "menuitem",
554
+ tabIndex: item.tabIndex,
555
+ });
529
556
 
530
- return (
531
- <button
532
- {...passed}
533
- aria-disabled={disabled ? "true" : undefined}
534
- id={item.id}
535
- onClick={composeHandlers(rest.onClick, item.onClick)}
536
- // The roving tab stop follows real focus rather than leading it, so a
537
- // pointer that moves focus and a key that moves focus agree without the
538
- // two of them having to be kept in step by hand.
539
- onFocus={composeHandlers(rest.onFocus, item.onFocus)}
540
- role="menuitem"
541
- tabIndex={item.tabIndex}
542
- type="button"
543
- >
544
- {children}
545
- </button>
546
- );
557
+ if (render != null) {
558
+ return render(props);
559
+ }
560
+ return <button {...props} type="button" />;
547
561
  }
548
562
 
549
563
  /**
@@ -570,28 +584,27 @@ export component MenuCheckboxItem(
570
584
  // header for why this default is the opposite of `Menu.Item`'s.
571
585
  closeOnSelect?: boolean = false,
572
586
  onSelect?: (event: MenuSelect) => mixed,
587
+ render?: RenderProp,
573
588
  ...rest: Rest
574
589
  ) {
575
590
  const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
576
591
  const toggle = useCallback(() => setOn(!on), [on, setOn]);
577
592
  const item = useMenuItem("Menu.CheckboxItem", disabled, closeOnSelect, onSelect, toggle);
578
- const passed = withoutComposed(rest, ["onClick", "onFocus"]);
593
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
594
+ "aria-checked": on ? "true" : "false",
595
+ "aria-disabled": disabled ? "true" : undefined,
596
+ children,
597
+ id: item.id,
598
+ onClick: composeHandlers(rest.onClick, item.onClick),
599
+ onFocus: composeHandlers(rest.onFocus, item.onFocus),
600
+ role: "menuitemcheckbox",
601
+ tabIndex: item.tabIndex,
602
+ });
579
603
 
580
- return (
581
- <button
582
- {...passed}
583
- aria-checked={on ? "true" : "false"}
584
- aria-disabled={disabled ? "true" : undefined}
585
- id={item.id}
586
- onClick={composeHandlers(rest.onClick, item.onClick)}
587
- onFocus={composeHandlers(rest.onFocus, item.onFocus)}
588
- role="menuitemcheckbox"
589
- tabIndex={item.tabIndex}
590
- type="button"
591
- >
592
- {children}
593
- </button>
594
- );
604
+ if (render != null) {
605
+ return render(props);
606
+ }
607
+ return <button {...props} type="button" />;
595
608
  }
596
609
 
597
610
  /**
@@ -613,6 +626,7 @@ export component MenuRadioGroup(
613
626
  defaultValue?: string | null = null,
614
627
  value?: string | null,
615
628
  onValueChange?: (value: string) => void,
629
+ render?: RenderProp,
616
630
  ...rest: Rest
617
631
  ) {
618
632
  const base = useId();
@@ -633,12 +647,16 @@ export component MenuRadioGroup(
633
647
  [selected, select],
634
648
  );
635
649
 
650
+ const props = withProps(rest, {
651
+ "aria-labelledby": labelled ? group.labelId : undefined,
652
+ children,
653
+ role: "group",
654
+ });
655
+
636
656
  return (
637
657
  <MenuGroupContext.Provider value={group}>
638
658
  <MenuRadioContext.Provider value={radio}>
639
- <div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
640
- {children}
641
- </div>
659
+ {render == null ? <div {...props} /> : render(props)}
642
660
  </MenuRadioContext.Provider>
643
661
  </MenuGroupContext.Provider>
644
662
  );
@@ -657,6 +675,7 @@ export component MenuRadioItem(
657
675
  disabled?: boolean = false,
658
676
  closeOnSelect?: boolean = false,
659
677
  onSelect?: (event: MenuSelect) => mixed,
678
+ render?: RenderProp,
660
679
  ...rest: Rest
661
680
  ) {
662
681
  const group = useContext(MenuRadioContext);
@@ -666,23 +685,21 @@ export component MenuRadioItem(
666
685
  const choose = group.choose;
667
686
  const pick = useCallback(() => choose(value), [choose, value]);
668
687
  const item = useMenuItem("Menu.RadioItem", disabled, closeOnSelect, onSelect, pick);
669
- const passed = withoutComposed(rest, ["onClick", "onFocus"]);
688
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus"]), {
689
+ "aria-checked": group.value === value ? "true" : "false",
690
+ "aria-disabled": disabled ? "true" : undefined,
691
+ children,
692
+ id: item.id,
693
+ onClick: composeHandlers(rest.onClick, item.onClick),
694
+ onFocus: composeHandlers(rest.onFocus, item.onFocus),
695
+ role: "menuitemradio",
696
+ tabIndex: item.tabIndex,
697
+ });
670
698
 
671
- return (
672
- <button
673
- {...passed}
674
- aria-checked={group.value === value ? "true" : "false"}
675
- aria-disabled={disabled ? "true" : undefined}
676
- id={item.id}
677
- onClick={composeHandlers(rest.onClick, item.onClick)}
678
- onFocus={composeHandlers(rest.onFocus, item.onFocus)}
679
- role="menuitemradio"
680
- tabIndex={item.tabIndex}
681
- type="button"
682
- >
683
- {children}
684
- </button>
685
- );
699
+ if (render != null) {
700
+ return render(props);
701
+ }
702
+ return <button {...props} type="button" />;
686
703
  }
687
704
 
688
705
  /**
@@ -692,10 +709,9 @@ export component MenuRadioItem(
692
709
  * is why it reads the list context of the menu around it and the menu context
693
710
  * of the one below it.
694
711
  */
695
- export component MenuSubTrigger(children: React.Node, ...rest: Rest) {
712
+ export component MenuSubTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
696
713
  const menu = useMenu("Menu.SubTrigger");
697
714
  const list = useContext(MenuListContext);
698
- const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]);
699
715
  const setActiveId = list?.setActiveId;
700
716
  useTriggerRegistration(menu);
701
717
  // The submenu's own trigger id, not a fresh one: the submenu names itself
@@ -707,36 +723,36 @@ export component MenuSubTrigger(children: React.Node, ...rest: Rest) {
707
723
  menu.setOpen(true);
708
724
  };
709
725
 
710
- return (
711
- <button
712
- {...passed}
713
- aria-controls={menu.open ? `${menu.base}-body` : undefined}
714
- aria-expanded={menu.open ? "true" : "false"}
715
- aria-haspopup="menu"
716
- id={id}
717
- onClick={composeHandlers(rest.onClick, open)}
718
- onFocus={composeHandlers(rest.onFocus, () => setActiveId?.(id))}
719
- onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
720
- const trigger: $FlowFixMe = event.currentTarget;
721
- if (event.key !== submenuKeys(directionOf(trigger)).open) {
722
- return;
723
- }
724
- event.preventDefault();
725
- // The parent menu's own `ArrowRight` does nothing, but a menu three
726
- // levels deep would otherwise see this key at every level.
727
- event.stopPropagation();
728
- open();
729
- })}
730
- ref={composeRefs(rest.ref, (element) => {
731
- menu.triggerRef.current = element;
732
- })}
733
- role="menuitem"
734
- tabIndex={list?.activeId === id ? 0 : -1}
735
- type="button"
736
- >
737
- {children}
738
- </button>
739
- );
726
+ const props = withProps(withoutComposed(rest, ["onClick", "onFocus", "onKeyDown", "ref"]), {
727
+ "aria-controls": menu.open ? `${menu.base}-body` : undefined,
728
+ "aria-expanded": menu.open ? "true" : "false",
729
+ "aria-haspopup": "menu",
730
+ children,
731
+ id,
732
+ onClick: composeHandlers(rest.onClick, open),
733
+ onFocus: composeHandlers(rest.onFocus, () => setActiveId?.(id)),
734
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
735
+ const trigger: $FlowFixMe = event.currentTarget;
736
+ if (event.key !== submenuKeys(directionOf(trigger)).open) {
737
+ return;
738
+ }
739
+ event.preventDefault();
740
+ // The parent menu's own `ArrowRight` does nothing, but a menu three
741
+ // levels deep would otherwise see this key at every level.
742
+ event.stopPropagation();
743
+ open();
744
+ }),
745
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
746
+ menu.triggerRef.current = element;
747
+ }),
748
+ role: "menuitem",
749
+ tabIndex: list?.activeId === id ? 0 : -1,
750
+ });
751
+
752
+ if (render != null) {
753
+ return render(props);
754
+ }
755
+ return <button {...props} type="button" />;
740
756
  }
741
757
 
742
758
  /**
@@ -746,8 +762,12 @@ export component MenuSubTrigger(children: React.Node, ...rest: Rest) {
746
762
  * moving through the menu is told the group changed. It is not focusable and
747
763
  * the arrow keys pass straight over it.
748
764
  */
749
- export component MenuSeparator(...rest: Rest) {
750
- return <div {...rest} aria-orientation="horizontal" role="separator" />;
765
+ export component MenuSeparator(render?: RenderProp, ...rest: Rest) {
766
+ const props = withProps(rest, { "aria-orientation": "horizontal", role: "separator" });
767
+ if (render != null) {
768
+ return render(props);
769
+ }
770
+ return <div {...props} />;
751
771
  }
752
772
 
753
773
  /**
@@ -758,17 +778,20 @@ export component MenuSeparator(...rest: Rest) {
758
778
  * that is not in the document makes a screen reader announce *nothing*, which
759
779
  * is worse than an unnamed group.
760
780
  */
761
- export component MenuGroup(children: React.Node, ...rest: Rest) {
781
+ export component MenuGroup(children: React.Node, render?: RenderProp, ...rest: Rest) {
762
782
  const base = useId();
763
783
  const [labelled, setLabelled] = useState(false);
764
784
 
765
785
  const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
786
+ const props = withProps(rest, {
787
+ "aria-labelledby": labelled ? group.labelId : undefined,
788
+ children,
789
+ role: "group",
790
+ });
766
791
 
767
792
  return (
768
793
  <MenuGroupContext.Provider value={group}>
769
- <div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
770
- {children}
771
- </div>
794
+ {render == null ? <div {...props} /> : render(props)}
772
795
  </MenuGroupContext.Provider>
773
796
  );
774
797
  }
@@ -780,7 +803,7 @@ export component MenuGroup(children: React.Node, ...rest: Rest) {
780
803
  * as ordinary content would have a reader hear the heading once as the group's
781
804
  * name and again as a stray line of text between the items.
782
805
  */
783
- export component MenuLabel(children: React.Node, ...rest: Rest) {
806
+ export component MenuLabel(children: React.Node, render?: RenderProp, ...rest: Rest) {
784
807
  const group = useContext(MenuGroupContext);
785
808
  const register = group?.registerLabel;
786
809
 
@@ -792,9 +815,9 @@ export component MenuLabel(children: React.Node, ...rest: Rest) {
792
815
  return () => register(false);
793
816
  }, [register]);
794
817
 
795
- return (
796
- <div {...rest} id={group?.labelId} role="presentation">
797
- {children}
798
- </div>
799
- );
818
+ const props = withProps(rest, { children, id: group?.labelId, role: "presentation" });
819
+ if (render != null) {
820
+ return render(props);
821
+ }
822
+ return <div {...props} />;
800
823
  }