@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.
@@ -1,17 +1,7 @@
1
- // this file is .vitest.tsx, not .test.tsx — so bun test won't pick it up.
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, vi } from "vitest";
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
- // jsdom (pretendToBeVisual) fires rAF callbacks every ~16 ms via setInterval,
37
- // so a short timer is needed for enqueueFocus() in @floating-ui/react to land.
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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 = vi.fn();
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
- open,
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
- getGroupProps: () => {
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
- getGroupLabelProps: (labelId: string) => {
460
- return elementProps({
461
- role: "presentation",
462
- id: labelId,
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
  }