@seed-design/react-menu 1.0.0 → 2.0.1
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/LICENSE +201 -0
- package/NOTICE +20 -0
- package/lib/{Menu-12s-BZEIsPP0.cjs → Menu-12s-B13-vH6b.cjs} +81 -74
- package/lib/{Menu-12s-Cfv1stLD.js → Menu-12s-DuVl-aKo.js} +82 -75
- package/lib/index.cjs +1 -1
- package/lib/index.d.ts +0 -567
- package/lib/index.js +2 -2
- package/lib/{useMenu-12s-Cv5FQJ3x.cjs → useMenu-12s-B5iYt8f4.cjs} +32 -48
- package/lib/{useMenu-12s-BrOJabEO.js → useMenu-12s-a4Nmkf8N.js} +33 -50
- package/package.json +11 -11
- package/src/Menu.tsx +79 -69
- package/src/{useMenu.vitest.tsx → useMenu.test.tsx} +140 -29
- package/src/useMenu.ts +33 -34
|
@@ -1,17 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// @floating-ui/react defers focus via requestAnimationFrame (enqueueFocus).
|
|
4
|
-
// bun test preloads happydom, which doesn't fire rAF the way we need.
|
|
5
|
-
// vitest + jsdom (pretendToBeVisual) gives us a real rAF that ticks at ~16ms,
|
|
6
|
-
// letting waitForFocus() actually flush the deferred .focus() calls.
|
|
7
|
-
//
|
|
8
|
-
// see vitest.config.ts for the jsdom environment setup.
|
|
9
|
-
|
|
10
|
-
/// <reference types="@testing-library/jest-dom/vitest" />
|
|
11
|
-
|
|
1
|
+
import { FocusScope } from "@radix-ui/react-focus-scope";
|
|
12
2
|
import { render, fireEvent, act } from "@testing-library/react";
|
|
13
3
|
import userEvent from "@testing-library/user-event";
|
|
14
|
-
import { describe, expect, it,
|
|
4
|
+
import { describe, expect, it, jest } from "bun:test";
|
|
15
5
|
|
|
16
6
|
import * as React from "react";
|
|
17
7
|
|
|
@@ -33,13 +23,28 @@ type UseMenuProps = MenuRootProps;
|
|
|
33
23
|
const waitForPositioning = () => act(async () => {});
|
|
34
24
|
|
|
35
25
|
// Flush rAF-deferred focus from FloatingFocusManager / useListNavigation.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
26
|
+
// happy-dom mocks rAF with setImmediate, so a short timer is needed for
|
|
27
|
+
// enqueueFocus() in @floating-ui/react to land.
|
|
38
28
|
const waitForFocus = () =>
|
|
39
29
|
act(async () => {
|
|
40
30
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
41
31
|
});
|
|
42
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Stands in for a modal ancestor (Dialog, Drawer, AppScreen). Those are all thin
|
|
35
|
+
* wrappers over exactly this scope, and what the menu owes them is entry in Radix's
|
|
36
|
+
* focusScopesStack — so the raw scope is the mechanism under test, not a stand-in
|
|
37
|
+
* for one. Using it directly also keeps the ancestor free of the scroll locking,
|
|
38
|
+
* aria-hidden and presence gating those components would drag in.
|
|
39
|
+
*/
|
|
40
|
+
function TrappedAncestor({ children }: { children: React.ReactNode }) {
|
|
41
|
+
return (
|
|
42
|
+
<FocusScope trapped onMountAutoFocus={(event) => event.preventDefault()}>
|
|
43
|
+
{children}
|
|
44
|
+
</FocusScope>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
43
48
|
function BasicMenu(props: UseMenuProps) {
|
|
44
49
|
return (
|
|
45
50
|
<Menu {...props}>
|
|
@@ -198,6 +203,31 @@ describe("useMenu", () => {
|
|
|
198
203
|
const label = getByText("Group 1");
|
|
199
204
|
expect(label).toHaveAttribute("id", labelledBy);
|
|
200
205
|
});
|
|
206
|
+
|
|
207
|
+
it("does not set a dangling aria-labelledby on a group without a label", async () => {
|
|
208
|
+
function MenuWithUnlabeledGroup() {
|
|
209
|
+
return (
|
|
210
|
+
<Menu>
|
|
211
|
+
<MenuTrigger>Open Menu</MenuTrigger>
|
|
212
|
+
<MenuPositioner>
|
|
213
|
+
<MenuContent>
|
|
214
|
+
<MenuGroup>
|
|
215
|
+
<MenuItem>Item A</MenuItem>
|
|
216
|
+
<MenuItem>Item B</MenuItem>
|
|
217
|
+
</MenuGroup>
|
|
218
|
+
</MenuContent>
|
|
219
|
+
</MenuPositioner>
|
|
220
|
+
</Menu>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const user = userEvent.setup();
|
|
225
|
+
const { getAllByRole, getByText } = render(<MenuWithUnlabeledGroup />);
|
|
226
|
+
await waitForPositioning();
|
|
227
|
+
await user.click(getByText("Open Menu"));
|
|
228
|
+
const group = getAllByRole("group")[0];
|
|
229
|
+
expect(group).not.toHaveAttribute("aria-labelledby");
|
|
230
|
+
});
|
|
201
231
|
});
|
|
202
232
|
|
|
203
233
|
describe("open/close state", () => {
|
|
@@ -222,7 +252,7 @@ describe("useMenu", () => {
|
|
|
222
252
|
|
|
223
253
|
it("supports controlled open state", async () => {
|
|
224
254
|
const user = userEvent.setup();
|
|
225
|
-
const onOpenChange =
|
|
255
|
+
const onOpenChange = jest.fn();
|
|
226
256
|
const { getByText } = render(<ControlledMenu onOpenChange={onOpenChange} />);
|
|
227
257
|
await waitForPositioning();
|
|
228
258
|
await user.click(getByText("Open Menu"));
|
|
@@ -237,7 +267,7 @@ describe("useMenu", () => {
|
|
|
237
267
|
|
|
238
268
|
it("calls onOpenChange when toggled", async () => {
|
|
239
269
|
const user = userEvent.setup();
|
|
240
|
-
const onOpenChange =
|
|
270
|
+
const onOpenChange = jest.fn();
|
|
241
271
|
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
242
272
|
await waitForPositioning();
|
|
243
273
|
await user.click(getByText("Open Menu"));
|
|
@@ -249,7 +279,7 @@ describe("useMenu", () => {
|
|
|
249
279
|
|
|
250
280
|
it("reports reason 'escapeKeyDown' when closed by Escape", async () => {
|
|
251
281
|
const user = userEvent.setup();
|
|
252
|
-
const onOpenChange =
|
|
282
|
+
const onOpenChange = jest.fn();
|
|
253
283
|
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
254
284
|
await waitForPositioning();
|
|
255
285
|
await user.click(getByText("Open Menu"));
|
|
@@ -263,7 +293,7 @@ describe("useMenu", () => {
|
|
|
263
293
|
|
|
264
294
|
it("reports reason 'interactOutside' when closed by outside click", async () => {
|
|
265
295
|
const user = userEvent.setup();
|
|
266
|
-
const onOpenChange =
|
|
296
|
+
const onOpenChange = jest.fn();
|
|
267
297
|
const { getByText } = render(
|
|
268
298
|
<div>
|
|
269
299
|
<BasicMenu onOpenChange={onOpenChange} />
|
|
@@ -282,7 +312,7 @@ describe("useMenu", () => {
|
|
|
282
312
|
|
|
283
313
|
it("reports reason 'itemClick' when closed by item selection", async () => {
|
|
284
314
|
const user = userEvent.setup();
|
|
285
|
-
const onOpenChange =
|
|
315
|
+
const onOpenChange = jest.fn();
|
|
286
316
|
const { getByText } = render(<BasicMenu onOpenChange={onOpenChange} />);
|
|
287
317
|
await waitForPositioning();
|
|
288
318
|
await user.click(getByText("Open Menu"));
|
|
@@ -529,7 +559,7 @@ describe("useMenu", () => {
|
|
|
529
559
|
describe("item interaction", () => {
|
|
530
560
|
it("calls onClick on item when clicked", async () => {
|
|
531
561
|
const user = userEvent.setup();
|
|
532
|
-
const onClick =
|
|
562
|
+
const onClick = jest.fn();
|
|
533
563
|
const { getByText } = render(
|
|
534
564
|
<Menu>
|
|
535
565
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -566,7 +596,7 @@ describe("useMenu", () => {
|
|
|
566
596
|
|
|
567
597
|
it("activates focused item on Enter key", async () => {
|
|
568
598
|
const user = userEvent.setup();
|
|
569
|
-
const onClick =
|
|
599
|
+
const onClick = jest.fn();
|
|
570
600
|
const { getByText } = render(
|
|
571
601
|
<Menu>
|
|
572
602
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -579,14 +609,14 @@ describe("useMenu", () => {
|
|
|
579
609
|
);
|
|
580
610
|
await waitForPositioning();
|
|
581
611
|
await user.click(getByText("Open Menu"));
|
|
582
|
-
getByText("Activatable").focus();
|
|
612
|
+
act(() => getByText("Activatable").focus());
|
|
583
613
|
await user.keyboard("{Enter}");
|
|
584
614
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
585
615
|
});
|
|
586
616
|
|
|
587
617
|
it("activates focused item on Space key", async () => {
|
|
588
618
|
const user = userEvent.setup();
|
|
589
|
-
const onClick =
|
|
619
|
+
const onClick = jest.fn();
|
|
590
620
|
const { getByText } = render(
|
|
591
621
|
<Menu>
|
|
592
622
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -599,14 +629,14 @@ describe("useMenu", () => {
|
|
|
599
629
|
);
|
|
600
630
|
await waitForPositioning();
|
|
601
631
|
await user.click(getByText("Open Menu"));
|
|
602
|
-
getByText("Activatable").focus();
|
|
632
|
+
act(() => getByText("Activatable").focus());
|
|
603
633
|
await user.keyboard(" ");
|
|
604
634
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
605
635
|
});
|
|
606
636
|
|
|
607
637
|
it("does not activate disabled item on click or keyboard", async () => {
|
|
608
638
|
const user = userEvent.setup();
|
|
609
|
-
const onClick =
|
|
639
|
+
const onClick = jest.fn();
|
|
610
640
|
const { getByText } = render(
|
|
611
641
|
<Menu>
|
|
612
642
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -736,7 +766,7 @@ describe("useMenu", () => {
|
|
|
736
766
|
|
|
737
767
|
it("does not cascade-dismiss Menu B when Menu A's layer is removed", async () => {
|
|
738
768
|
const user = userEvent.setup();
|
|
739
|
-
const onMenuBOpenChange =
|
|
769
|
+
const onMenuBOpenChange = jest.fn();
|
|
740
770
|
const { getByText } = render(<TwoSiblingMenus onMenuBOpenChange={onMenuBOpenChange} />);
|
|
741
771
|
await waitForPositioning();
|
|
742
772
|
|
|
@@ -785,7 +815,7 @@ describe("useMenu", () => {
|
|
|
785
815
|
|
|
786
816
|
it("does not cascade-dismiss Menu B on touch switch (touch)", async () => {
|
|
787
817
|
const user = userEvent.setup();
|
|
788
|
-
const onMenuBOpenChange =
|
|
818
|
+
const onMenuBOpenChange = jest.fn();
|
|
789
819
|
const { getByText } = render(<TwoSiblingMenus onMenuBOpenChange={onMenuBOpenChange} />);
|
|
790
820
|
await waitForPositioning();
|
|
791
821
|
|
|
@@ -813,7 +843,7 @@ describe("useMenu", () => {
|
|
|
813
843
|
|
|
814
844
|
it("keeps Menu B open and interactive after switching from Menu A", async () => {
|
|
815
845
|
const user = userEvent.setup();
|
|
816
|
-
const onClick =
|
|
846
|
+
const onClick = jest.fn();
|
|
817
847
|
const { getByText } = render(
|
|
818
848
|
<div>
|
|
819
849
|
<Menu>
|
|
@@ -848,7 +878,7 @@ describe("useMenu", () => {
|
|
|
848
878
|
|
|
849
879
|
describe("mouse interaction", () => {
|
|
850
880
|
it("activates item on mouse-up after drag from trigger", async () => {
|
|
851
|
-
const onClick =
|
|
881
|
+
const onClick = jest.fn();
|
|
852
882
|
const { getByText } = render(
|
|
853
883
|
<Menu>
|
|
854
884
|
<MenuTrigger>Open Menu</MenuTrigger>
|
|
@@ -892,4 +922,85 @@ describe("useMenu", () => {
|
|
|
892
922
|
expect(getByText("Item 2 (disabled)")).toHaveAttribute("data-disabled");
|
|
893
923
|
});
|
|
894
924
|
});
|
|
925
|
+
|
|
926
|
+
// The content stays mounted while closing so the exit transition can play, and it
|
|
927
|
+
// has to keep the very same DOM nodes: a remount hands that transition a fresh
|
|
928
|
+
// scroll container starting at scrollTop 0, so a long menu visibly snaps back to
|
|
929
|
+
// the top the moment it starts closing. Both tests assert on DOM state React never
|
|
930
|
+
// renders — exactly what a remount throws away.
|
|
931
|
+
describe("close transition", () => {
|
|
932
|
+
it("preserves the content's scroll position through a close", async () => {
|
|
933
|
+
const user = userEvent.setup();
|
|
934
|
+
const { getByText, getByRole } = render(<BasicMenu />);
|
|
935
|
+
await waitForPositioning();
|
|
936
|
+
|
|
937
|
+
const trigger = getByText("Open Menu");
|
|
938
|
+
await user.click(trigger);
|
|
939
|
+
getByRole("menu").scrollTop = 120;
|
|
940
|
+
|
|
941
|
+
await user.click(trigger);
|
|
942
|
+
expect(getByRole("menu")).not.toHaveAttribute("data-open");
|
|
943
|
+
expect(getByRole("menu").scrollTop).toBe(120);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
it("keeps the same item elements when closing by clicking an item", async () => {
|
|
947
|
+
const user = userEvent.setup();
|
|
948
|
+
const { getByText, getByRole, getAllByRole } = render(<BasicMenu />);
|
|
949
|
+
await waitForPositioning();
|
|
950
|
+
|
|
951
|
+
await user.click(getByText("Open Menu"));
|
|
952
|
+
getAllByRole("menuitem").forEach((item, index) =>
|
|
953
|
+
item.setAttribute("data-probe", String(index)),
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
await user.click(getByText("Item 2"));
|
|
957
|
+
expect(getByRole("menu")).not.toHaveAttribute("data-open");
|
|
958
|
+
|
|
959
|
+
const probes = getAllByRole("menuitem").map((item) => item.getAttribute("data-probe"));
|
|
960
|
+
expect(probes).toEqual(["0", "1", "2"]);
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
describe("focus scope participation", () => {
|
|
965
|
+
it("pauses a trapped ancestor while open", async () => {
|
|
966
|
+
const user = userEvent.setup();
|
|
967
|
+
const { getByText, getAllByRole } = render(
|
|
968
|
+
<TrappedAncestor>
|
|
969
|
+
<BasicMenu />
|
|
970
|
+
</TrappedAncestor>,
|
|
971
|
+
);
|
|
972
|
+
await waitForPositioning();
|
|
973
|
+
|
|
974
|
+
getByText("Open Menu").focus();
|
|
975
|
+
await user.keyboard("{ArrowDown}");
|
|
976
|
+
await waitForFocus();
|
|
977
|
+
|
|
978
|
+
expect(getAllByRole("menuitem")[0]).toHaveFocus();
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// Pause and resume are one contract: the scope has to leave the stack when the
|
|
982
|
+
// menu closes, or the ancestor stays paused forever and its trap never comes back.
|
|
983
|
+
it("lets a trapped ancestor resume once closed", async () => {
|
|
984
|
+
const user = userEvent.setup();
|
|
985
|
+
const { getByText } = render(
|
|
986
|
+
<>
|
|
987
|
+
<button type="button">Outside</button>
|
|
988
|
+
<TrappedAncestor>
|
|
989
|
+
<BasicMenu />
|
|
990
|
+
</TrappedAncestor>
|
|
991
|
+
</>,
|
|
992
|
+
);
|
|
993
|
+
await waitForPositioning();
|
|
994
|
+
|
|
995
|
+
const trigger = getByText("Open Menu");
|
|
996
|
+
trigger.focus();
|
|
997
|
+
await user.click(trigger);
|
|
998
|
+
await user.click(trigger);
|
|
999
|
+
await waitForFocus();
|
|
1000
|
+
|
|
1001
|
+
// With the ancestor trap active again, focus cannot settle outside its container.
|
|
1002
|
+
act(() => getByText("Outside").focus());
|
|
1003
|
+
expect(getByText("Outside")).not.toHaveFocus();
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
895
1006
|
});
|
package/src/useMenu.ts
CHANGED
|
@@ -142,11 +142,6 @@ function useMenuState(props: UseMenuStateProps) {
|
|
|
142
142
|
const labelsRef = useRef<(string | null)[]>([]);
|
|
143
143
|
const triggerRef = useRef<Element | null>(null);
|
|
144
144
|
|
|
145
|
-
const groupIndexCounter = useRef(0);
|
|
146
|
-
groupIndexCounter.current = 0;
|
|
147
|
-
|
|
148
|
-
const menuId = useId();
|
|
149
|
-
|
|
150
145
|
return {
|
|
151
146
|
open,
|
|
152
147
|
setOpenState,
|
|
@@ -155,23 +150,12 @@ function useMenuState(props: UseMenuStateProps) {
|
|
|
155
150
|
elementsRef,
|
|
156
151
|
labelsRef,
|
|
157
152
|
triggerRef,
|
|
158
|
-
groupIndexCounter,
|
|
159
|
-
menuId,
|
|
160
153
|
};
|
|
161
154
|
}
|
|
162
155
|
|
|
163
156
|
export function useMenu(props: UseMenuProps) {
|
|
164
|
-
const {
|
|
165
|
-
|
|
166
|
-
setOpenState,
|
|
167
|
-
activeIndex,
|
|
168
|
-
setActiveIndex,
|
|
169
|
-
elementsRef,
|
|
170
|
-
labelsRef,
|
|
171
|
-
triggerRef,
|
|
172
|
-
groupIndexCounter,
|
|
173
|
-
menuId,
|
|
174
|
-
} = useMenuState(props);
|
|
157
|
+
const { open, setOpenState, activeIndex, setActiveIndex, elementsRef, labelsRef, triggerRef } =
|
|
158
|
+
useMenuState(props);
|
|
175
159
|
|
|
176
160
|
const {
|
|
177
161
|
disabled = false,
|
|
@@ -443,24 +427,39 @@ export function useMenu(props: UseMenuProps) {
|
|
|
443
427
|
// }),
|
|
444
428
|
// };
|
|
445
429
|
// },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
446
432
|
|
|
447
|
-
|
|
448
|
-
const groupIndex = groupIndexCounter.current++;
|
|
449
|
-
const labelId = `menu:${menuId}:group-${groupIndex}:label`;
|
|
450
|
-
return {
|
|
451
|
-
labelId,
|
|
452
|
-
rootProps: elementProps({
|
|
453
|
-
role: "group",
|
|
454
|
-
"aria-labelledby": labelId,
|
|
455
|
-
}),
|
|
456
|
-
};
|
|
457
|
-
},
|
|
433
|
+
export type UseMenuGroupReturn = ReturnType<typeof useMenuGroup>;
|
|
458
434
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
435
|
+
// A group advertises aria-labelledby only while its label is actually rendered.
|
|
436
|
+
// Mirrors useFieldset: a callback ref flips a boolean synchronously at commit, so
|
|
437
|
+
// the attribute is present on first paint and never points at a missing id — no
|
|
438
|
+
// menu-global registry or render-order ids needed. Each group owns its own id and
|
|
439
|
+
// presence flag, so conditionally rendering or reordering groups can never make one
|
|
440
|
+
// group inherit another's label reference.
|
|
441
|
+
export function useMenuGroup() {
|
|
442
|
+
const id = useId();
|
|
443
|
+
const labelId = `menu-group:${id}:label`;
|
|
444
|
+
|
|
445
|
+
const [isLabelRendered, setIsLabelRendered] = useState(false);
|
|
446
|
+
const labelRef = useCallback((node: HTMLDivElement | null) => {
|
|
447
|
+
setIsLabelRendered(!!node);
|
|
448
|
+
}, []);
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
refs: {
|
|
452
|
+
label: labelRef,
|
|
464
453
|
},
|
|
454
|
+
|
|
455
|
+
rootProps: elementProps({
|
|
456
|
+
role: "group",
|
|
457
|
+
...(isLabelRendered && { "aria-labelledby": labelId }),
|
|
458
|
+
}),
|
|
459
|
+
|
|
460
|
+
labelProps: elementProps({
|
|
461
|
+
role: "presentation",
|
|
462
|
+
id: labelId,
|
|
463
|
+
}),
|
|
465
464
|
};
|
|
466
465
|
}
|