@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/accordion.js +61 -54
- package/alert-dialog.js +43 -17
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/checkbox.js +45 -39
- package/collapsible.js +41 -36
- package/context-menu.js +32 -24
- package/dialog.js +143 -119
- package/drawer.js +121 -89
- package/field.js +71 -37
- package/hover-card.js +23 -27
- package/index.js +567 -31
- package/input-otp.js +8 -8
- package/interactions.js +2163 -0
- package/internal/merge-props.js +85 -1
- package/menu.js +236 -213
- package/menubar.js +91 -89
- package/package.json +8 -40
- package/pagination.js +29 -17
- package/popover.js +59 -55
- package/progress.js +21 -16
- package/radio-group.js +79 -75
- package/separator.js +97 -0
- package/sheet.js +41 -17
- package/sidebar.js +10 -10
- package/skeleton.js +159 -0
- package/slider.js +89 -89
- package/switch.js +37 -31
- package/table.js +90 -67
- package/tabs.js +100 -91
- package/toast.js +23 -55
- package/toggle-group.js +52 -49
- package/toggle.js +38 -25
- package/tooltip.js +26 -37
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 {
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 {...
|
|
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
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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 {...
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
}
|