@sproutsocial/seeds-react-menu 0.1.0

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.
Files changed (91) hide show
  1. package/.eslintrc.js +4 -0
  2. package/.turbo/turbo-build.log +21 -0
  3. package/CHANGELOG.md +46 -0
  4. package/dist/esm/index.js +2651 -0
  5. package/dist/esm/index.js.map +1 -0
  6. package/dist/index.d.mts +197 -0
  7. package/dist/index.d.ts +197 -0
  8. package/dist/index.js +2670 -0
  9. package/dist/index.js.map +1 -0
  10. package/jest.config.js +12 -0
  11. package/package.json +50 -0
  12. package/src/ActionMenu/ActionMenu.feature +20 -0
  13. package/src/ActionMenu/ActionMenu.tsx +113 -0
  14. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +99 -0
  15. package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +73 -0
  16. package/src/ActionMenu/index.ts +1 -0
  17. package/src/ComboBox/ComboBox.feature +96 -0
  18. package/src/ComboBox/TokenInput.feature +84 -0
  19. package/src/MenuContent/MenuContent.tsx +28 -0
  20. package/src/MenuContent/MenuContentTypes.ts +7 -0
  21. package/src/MenuContent/__tests__/MenuContent.test.tsx +59 -0
  22. package/src/MenuContent/__tests__/MenuContent.typetest.tsx +18 -0
  23. package/src/MenuContent/index.tsx +2 -0
  24. package/src/MenuContent/styles.tsx +10 -0
  25. package/src/MenuContext.tsx +116 -0
  26. package/src/MenuFooter/MenuFooter.feature +30 -0
  27. package/src/MenuFooter/MenuFooter.tsx +7 -0
  28. package/src/MenuFooter/MenuFooterTypes.ts +1 -0
  29. package/src/MenuFooter/__tests__/MenuFooter.test.tsx +76 -0
  30. package/src/MenuFooter/__tests__/MenuFooter.typetest.tsx +20 -0
  31. package/src/MenuFooter/index.ts +2 -0
  32. package/src/MenuFooter/styles.tsx +7 -0
  33. package/src/MenuGroup/MenuGroup.feature +114 -0
  34. package/src/MenuGroup/MenuGroup.tsx +33 -0
  35. package/src/MenuGroup/MenuGroupTypes.ts +29 -0
  36. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +95 -0
  37. package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +45 -0
  38. package/src/MenuGroup/index.tsx +8 -0
  39. package/src/MenuGroup/styles.tsx +37 -0
  40. package/src/MenuHeader/MenuHeader.feature +59 -0
  41. package/src/MenuHeader/MenuHeader.tsx +30 -0
  42. package/src/MenuHeader/MenuHeaderTypes.ts +26 -0
  43. package/src/MenuHeader/__tests__/MenuHeader.test.tsx +67 -0
  44. package/src/MenuHeader/__tests__/MenuHeader.typetest.tsx +27 -0
  45. package/src/MenuHeader/index.ts +2 -0
  46. package/src/MenuHeader/styles.tsx +75 -0
  47. package/src/MenuItem/MenuItem.feature +88 -0
  48. package/src/MenuItem/MenuItem.tsx +44 -0
  49. package/src/MenuItem/MenuItemTypes.ts +37 -0
  50. package/src/MenuItem/__tests__/MenuItem.test.tsx +228 -0
  51. package/src/MenuItem/__tests__/MenuItem.typetest.tsx +41 -0
  52. package/src/MenuItem/index.ts +5 -0
  53. package/src/MenuItem/styles.tsx +71 -0
  54. package/src/MenuItemSelectable/MenuItemSelectable.feature +99 -0
  55. package/src/MenuItemSelectable/MenuItemSelectable.tsx +136 -0
  56. package/src/MenuItemSelectable/MenuItemSelectableTypes.ts +15 -0
  57. package/src/MenuItemSelectable/__tests__/MenuItemSelectable.test.tsx +303 -0
  58. package/src/MenuItemSelectable/__tests__/MenuItemSelectable.typetest.tsx +56 -0
  59. package/src/MenuItemSelectable/index.ts +5 -0
  60. package/src/MenuItemSelectable/styles.tsx +21 -0
  61. package/src/MenuLabel.tsx +12 -0
  62. package/src/MenuRoot/MenuRoot.feature +93 -0
  63. package/src/MenuRoot/MenuRoot.tsx +70 -0
  64. package/src/MenuRoot/__tests__/MenuRoot.test.tsx +197 -0
  65. package/src/MenuRoot/__tests__/MenuRoot.typetest.tsx +76 -0
  66. package/src/MenuRoot/index.ts +1 -0
  67. package/src/MenuToggleButton.tsx +29 -0
  68. package/src/MultiSelectMenu/MultiSelectMenu.feature +70 -0
  69. package/src/MultiSelectMenu/MultiSelectMenu.tsx +133 -0
  70. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +8 -0
  71. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +349 -0
  72. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +78 -0
  73. package/src/MultiSelectMenu/index.ts +1 -0
  74. package/src/SingleSelectMenu/SingleSelectMenu.feature +23 -0
  75. package/src/SingleSelectMenu/SingleSelectMenu.tsx +56 -0
  76. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +9 -0
  77. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.test.tsx +167 -0
  78. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +95 -0
  79. package/src/SingleSelectMenu/index.ts +3 -0
  80. package/src/__tests__/Menu.test.tsx +423 -0
  81. package/src/constants.ts +17 -0
  82. package/src/hooks/useItemsFromChildren.tsx +81 -0
  83. package/src/index.ts +20 -0
  84. package/src/menuTestingUtils.tsx +72 -0
  85. package/src/reducers/__tests__/useSelectStateReducer.test.tsx +37 -0
  86. package/src/reducers/useSelectStateReducer.tsx +27 -0
  87. package/src/styled.d.ts +7 -0
  88. package/src/types.ts +16 -0
  89. package/src/utils/itemToString.ts +5 -0
  90. package/tsconfig.json +12 -0
  91. package/tsup.config.ts +11 -0
@@ -0,0 +1,167 @@
1
+ /* eslint-disable testing-library/no-unnecessary-act */
2
+ import React from "react";
3
+ import {
4
+ render,
5
+ screen,
6
+ waitFor,
7
+ act,
8
+ } from "@sproutsocial/seeds-react-testing-library";
9
+ import {
10
+ SingleSelectMenu,
11
+ MenuToggleButton,
12
+ MenuContent,
13
+ MenuGroup,
14
+ MenuItemSelectable,
15
+ MenuFooter,
16
+ } from "../../index";
17
+ import { Switch } from "@sproutsocial/seeds-react-switch";
18
+
19
+ const menuItems = [
20
+ { id: "Select an option", index: 0, label: "Select an option" },
21
+ { id: "item-1", index: 1, label: "Item 1" },
22
+ { id: "item-2", index: 2, label: "Item 2" },
23
+ { id: "item-3", index: 3, label: "Item 3" },
24
+ ];
25
+
26
+ describe("User Behavior", () => {
27
+ it("should set a selected item", async () => {
28
+ const checkOnChange = jest.fn();
29
+
30
+ const { user } = render(
31
+ <SingleSelectMenu
32
+ onSelectedItemChange={checkOnChange}
33
+ triggerElement={
34
+ <MenuToggleButton role="button">Click me</MenuToggleButton>
35
+ }
36
+ >
37
+ <MenuContent>
38
+ <MenuGroup title="Menu Title">
39
+ {menuItems.map(({ id, label }) => (
40
+ <MenuItemSelectable key={id} id={id}>
41
+ {label}
42
+ </MenuItemSelectable>
43
+ ))}
44
+ </MenuGroup>
45
+ </MenuContent>
46
+ </SingleSelectMenu>
47
+ );
48
+
49
+ // Open the Menu
50
+ const toggleBtn = screen.getByRole("button", { name: "Click me" });
51
+ await user.click(toggleBtn);
52
+
53
+ await waitFor(() => expect(screen.getByRole("listbox")).toBeVisible());
54
+
55
+ // Select the first option
56
+ const item1 = screen.getByRole("option", { name: menuItems[0].label });
57
+ expect(item1).toBeVisible();
58
+ await user.keyboard("{arrowdown}{enter}");
59
+
60
+ // Wait for menu to close
61
+ await waitFor(() =>
62
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
63
+ );
64
+
65
+ expect(checkOnChange).toHaveBeenCalledTimes(1);
66
+
67
+ // Reopen the menu
68
+ await user.click(toggleBtn);
69
+
70
+ await waitFor(() => expect(screen.getByRole("listbox")).toBeVisible());
71
+
72
+ // Check that the selected option is selected
73
+ expect(
74
+ screen.getByRole("option", { name: menuItems[0].label, selected: true })
75
+ ).toBeInTheDocument();
76
+ });
77
+
78
+ it("should keep menu open when a switch is selected in the menu footer", async () => {
79
+ const MenuFooterContent = () => {
80
+ const [isChecked, setIsChecked] = React.useState(false);
81
+ return (
82
+ <MenuFooter>
83
+ <ul>
84
+ <li>
85
+ <label htmlFor="switch-1">dark mode</label>
86
+ <Switch
87
+ id="switch-1"
88
+ checked={isChecked}
89
+ onClick={() => setIsChecked(!isChecked)}
90
+ />
91
+ </li>
92
+ </ul>
93
+ </MenuFooter>
94
+ );
95
+ };
96
+
97
+ const { user } = render(
98
+ <SingleSelectMenu
99
+ triggerElement={
100
+ <MenuToggleButton role="button">Click me</MenuToggleButton>
101
+ }
102
+ >
103
+ <MenuContent>
104
+ <MenuGroup title="Menu Title">
105
+ {menuItems.map(({ id, label }) => (
106
+ <MenuItemSelectable key={id} id={id}>
107
+ {label}
108
+ </MenuItemSelectable>
109
+ ))}
110
+ </MenuGroup>
111
+ </MenuContent>
112
+ <MenuFooterContent />
113
+ </SingleSelectMenu>
114
+ );
115
+
116
+ // Open the Menu
117
+ const toggleBtn = screen.getByRole("button", { name: "Click me" });
118
+ await user.click(toggleBtn);
119
+
120
+ await waitFor(() => expect(screen.getByRole("listbox")).toBeVisible());
121
+
122
+ // Toggle the switch
123
+ const switchElement = screen.getByRole("switch", { name: "dark mode" });
124
+ await user.click(switchElement);
125
+
126
+ // Check that the switch is checked
127
+ expect(switchElement).toBeChecked();
128
+
129
+ // Check that the menu is still open
130
+ expect(screen.getByRole("listbox")).toBeVisible();
131
+ });
132
+ });
133
+
134
+ describe("API", () => {
135
+ it("should close automatically on selection", async () => {
136
+ const { user } = render(
137
+ <SingleSelectMenu
138
+ triggerElement={<MenuToggleButton>Click me</MenuToggleButton>}
139
+ >
140
+ <MenuContent>
141
+ <MenuGroup title="Menu Title">
142
+ {menuItems.map(({ id, label }) => (
143
+ <MenuItemSelectable key={id} id={id}>
144
+ {label}
145
+ </MenuItemSelectable>
146
+ ))}
147
+ </MenuGroup>
148
+ </MenuContent>
149
+ </SingleSelectMenu>
150
+ );
151
+
152
+ // Open the Menu
153
+ const menuButton = screen.getByText("Click me");
154
+ await user.click(menuButton);
155
+ expect(await screen.findByText("Menu Title")).toBeInTheDocument();
156
+
157
+ // Click one of the options
158
+ const menuItem = screen.getAllByRole("option")[0];
159
+ expect(menuItem).toBeInTheDocument();
160
+ await user.click(menuItem);
161
+
162
+ // Check that the menu closed
163
+ await waitFor(() =>
164
+ expect(screen.queryByText("Menu Title")).not.toBeInTheDocument()
165
+ );
166
+ });
167
+ });
@@ -0,0 +1,95 @@
1
+ import {
2
+ SingleSelectMenu,
3
+ MenuContent,
4
+ MenuHeader,
5
+ MenuFooter,
6
+ } from "../../index";
7
+
8
+ export const SingleSelectMenuTypeTest = () => (
9
+ <>
10
+ {/* Valid SingleSelectMenu with minimal required props */}
11
+ <SingleSelectMenu triggerElement={<>test</>}>Test</SingleSelectMenu>
12
+ {/* Valid SingleSelectMenu allows MenuContent by itself as a child */}
13
+ <SingleSelectMenu triggerElement={<>test</>}>
14
+ <MenuContent>Test</MenuContent>
15
+ </SingleSelectMenu>
16
+ {/* Valid SingleSelectMenu allows multiple children without fragment */}
17
+ <SingleSelectMenu triggerElement={<>test</>}>
18
+ <MenuHeader>Test</MenuHeader>
19
+ <MenuContent>Test</MenuContent>
20
+ <MenuFooter>Test</MenuFooter>
21
+ </SingleSelectMenu>
22
+ {/* Valid SingleSelectMenu accepts Downshift props and functions for useSelect */}
23
+ <SingleSelectMenu
24
+ triggerElement={<>test</>}
25
+ onSelectedItemChange={() => {}}
26
+ selectedItem={null}
27
+ stateReducer={(c) => c}
28
+ onStateChange={() => {}}
29
+ getA11yStatusMessage={() => ""}
30
+ isOpen
31
+ >
32
+ Test
33
+ </SingleSelectMenu>
34
+
35
+ {/* @ts-expect-error - triggerElement is a required prop */}
36
+ <SingleSelectMenu>Test</SingleSelectMenu>
37
+ <SingleSelectMenu
38
+ /* @ts-expect-error - triggerElement must be a valid react element */
39
+ triggerElement="test"
40
+ >
41
+ Test
42
+ </SingleSelectMenu>
43
+ {/* @ts-expect-error - when no children provided */}
44
+ <SingleSelectMenu triggerElement="test" />
45
+
46
+ <SingleSelectMenu
47
+ triggerElement={<>test</>}
48
+ /* @ts-expect-error - isOpen can't be a string */
49
+ isOpen="false"
50
+ /* @ts-expect-error - onSelectedItemChange can't be a string */
51
+ onSelectedItemChange="test"
52
+ /* @ts-expect-error - selectedItem can't be a string */
53
+ selectedItem="test"
54
+ /* @ts-expect-error - stateReducer must have a return value */
55
+ stateReducer={() => {}}
56
+ >
57
+ Test
58
+ </SingleSelectMenu>
59
+ <SingleSelectMenu
60
+ triggerElement={<>test</>}
61
+ /* @ts-expect-error - onMultiSelectStateChange can't be used on SingleSelectMenu */
62
+ onMultiSelectStateChange={() => {}}
63
+ >
64
+ Test
65
+ </SingleSelectMenu>
66
+ <SingleSelectMenu
67
+ triggerElement={<>test</>}
68
+ /* @ts-expect-error - selectedItems can't be used on SingleSelectMenu */
69
+ selectedItems={[]}
70
+ >
71
+ Test
72
+ </SingleSelectMenu>
73
+ <SingleSelectMenu
74
+ triggerElement={<>test</>}
75
+ /* @ts-expect-error - multiSelectStateReducer can't be used on SingleSelectMenu */
76
+ multiSelectStateReducer={(c) => c}
77
+ >
78
+ Test
79
+ </SingleSelectMenu>
80
+ <SingleSelectMenu
81
+ triggerElement={<>test</>}
82
+ /* @ts-expect-error - onMultiSelectStateChange can't be used on SingleSelectMenu */
83
+ onMultiSelectStateChange={() => {}}
84
+ >
85
+ Test
86
+ </SingleSelectMenu>
87
+ <SingleSelectMenu
88
+ triggerElement={<>test</>}
89
+ /* @ts-expect-error - getA11yMultiStatusMessage can't be used on SingleSelectMenu */
90
+ getA11yMultiStatusMessage={() => ""}
91
+ >
92
+ Test
93
+ </SingleSelectMenu>
94
+ </>
95
+ );
@@ -0,0 +1,3 @@
1
+ export * from "./SingleSelectMenu";
2
+
3
+ export * from "./SingleSelectMenuTypes";
@@ -0,0 +1,423 @@
1
+ /* eslint-disable testing-library/no-unnecessary-act */
2
+ import {
3
+ render,
4
+ screen,
5
+ waitFor,
6
+ act,
7
+ } from "@sproutsocial/seeds-react-testing-library";
8
+ import {
9
+ ActionMenu,
10
+ MultiSelectMenu,
11
+ SingleSelectMenu,
12
+ MenuToggleButton,
13
+ MenuContent,
14
+ MenuItem,
15
+ } from "../index";
16
+
17
+ describe("Menu", () => {
18
+ describe.each([
19
+ { name: "ActionMenu", Component: ActionMenu },
20
+ { name: "MultiSelectMenu", Component: MultiSelectMenu },
21
+ { name: "SingleSelectMenu", Component: SingleSelectMenu },
22
+ ])("$name", ({ Component }) => {
23
+ it("should render the trigger button", () => {
24
+ render(
25
+ <Component
26
+ triggerElement={
27
+ <MenuToggleButton role="button" aria-label="Open Menu">
28
+ Open
29
+ </MenuToggleButton>
30
+ }
31
+ >
32
+ Menu
33
+ </Component>
34
+ );
35
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
36
+ expect(menuTrigger).toBeInTheDocument();
37
+ expect(screen.queryByText("Menu")).not.toBeInTheDocument();
38
+ });
39
+
40
+ it("should default to open if initialIsOpen is true", () => {
41
+ render(
42
+ <Component
43
+ triggerElement={
44
+ <MenuToggleButton role="button" aria-label="Open Menu">
45
+ Open
46
+ </MenuToggleButton>
47
+ }
48
+ initialIsOpen
49
+ >
50
+ Menu
51
+ </Component>
52
+ );
53
+ const menuTrigger = screen.getByRole("button", {
54
+ name: "Open Menu",
55
+ expanded: true,
56
+ });
57
+ expect(menuTrigger).toBeInTheDocument();
58
+ expect(screen.getByText("Menu")).toBeInTheDocument();
59
+ });
60
+
61
+ it("should open when trigger button is clicked", async () => {
62
+ const { user } = render(
63
+ <Component
64
+ triggerElement={
65
+ <MenuToggleButton role="button" aria-label="Open Menu">
66
+ Open
67
+ </MenuToggleButton>
68
+ }
69
+ >
70
+ Menu
71
+ </Component>
72
+ );
73
+
74
+ // Check that Menu is not open yet
75
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
76
+ expect(menuTrigger).toBeInTheDocument();
77
+ expect(screen.queryByText("Menu")).not.toBeInTheDocument();
78
+
79
+ // Click the menu trigger
80
+ await user.click(menuTrigger);
81
+
82
+ // Check that menu opens
83
+ expect(await screen.findByText("Menu")).toBeInTheDocument();
84
+ });
85
+
86
+ it("should open when trigger button is focused and enter key pressed", async () => {
87
+ const { user } = render(
88
+ <Component
89
+ triggerElement={
90
+ <MenuToggleButton role="button" aria-label="Open Menu">
91
+ Open
92
+ </MenuToggleButton>
93
+ }
94
+ >
95
+ Menu
96
+ </Component>
97
+ );
98
+
99
+ // Check that Menu is not open yet
100
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
101
+ expect(menuTrigger).toBeInTheDocument();
102
+ expect(screen.queryByText("Menu")).not.toBeInTheDocument();
103
+
104
+ // Focus on trigger and activate with enter
105
+ menuTrigger.focus();
106
+ await user.keyboard("{enter}");
107
+
108
+ // Check that menu opens
109
+ expect(await screen.findByText("Menu")).toBeInTheDocument();
110
+ });
111
+
112
+ it("should open when trigger button is focused and space key pressed", async () => {
113
+ const { user } = render(
114
+ <Component
115
+ triggerElement={
116
+ <MenuToggleButton role="button" aria-label="Open Menu">
117
+ Open
118
+ </MenuToggleButton>
119
+ }
120
+ >
121
+ Menu
122
+ </Component>
123
+ );
124
+
125
+ // Check that Menu is not open yet
126
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
127
+ expect(menuTrigger).toBeInTheDocument();
128
+ expect(screen.queryByText("Menu")).not.toBeInTheDocument();
129
+
130
+ // Focus on trigger and activate with space
131
+ menuTrigger.focus();
132
+ await user.keyboard(" ");
133
+
134
+ // Check that menu opens
135
+ expect(await screen.findByText("Menu")).toBeInTheDocument();
136
+ });
137
+
138
+ it("should close when trigger button is clicked while open", async () => {
139
+ const { user } = render(
140
+ <Component
141
+ triggerElement={
142
+ <MenuToggleButton role="button" aria-label="Open Menu">
143
+ Open
144
+ </MenuToggleButton>
145
+ }
146
+ initialIsOpen
147
+ >
148
+ Menu
149
+ </Component>
150
+ );
151
+
152
+ // Check that Menu is open
153
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
154
+ expect(menuTrigger).toBeInTheDocument();
155
+ expect(await screen.findByText("Menu")).toBeInTheDocument();
156
+
157
+ // Click the menu trigger
158
+ await user.click(menuTrigger);
159
+
160
+ // Check that menu closes
161
+ await waitFor(() =>
162
+ expect(screen.queryByText("Menu")).not.toBeInTheDocument()
163
+ );
164
+ });
165
+
166
+ it("should close when the ESC key is pressed", async () => {
167
+ const mockOnIsOpenChange = jest.fn();
168
+ const { user } = render(
169
+ <Component
170
+ triggerElement={
171
+ <MenuToggleButton role="button" aria-label="Open Menu">
172
+ Open
173
+ </MenuToggleButton>
174
+ }
175
+ onIsOpenChange={mockOnIsOpenChange}
176
+ initialIsOpen
177
+ >
178
+ Menu
179
+ </Component>
180
+ );
181
+
182
+ // Check that Menu is open
183
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
184
+ expect(menuTrigger).toBeInTheDocument();
185
+ expect(await screen.findByText("Menu")).toBeInTheDocument();
186
+
187
+ // Press esc key
188
+ await user.keyboard("{Esc}");
189
+
190
+ // Check that menu closes
191
+ await waitFor(() =>
192
+ expect(screen.queryByText("Menu")).not.toBeInTheDocument()
193
+ );
194
+ expect(mockOnIsOpenChange).toHaveBeenCalled();
195
+ });
196
+
197
+ it("should close when clicking outside of the menu", async () => {
198
+ const mockOnIsOpenChange = jest.fn();
199
+ const { user } = render(
200
+ <div>
201
+ <Component
202
+ triggerElement={
203
+ <MenuToggleButton role="button" aria-label="Open Menu">
204
+ Open
205
+ </MenuToggleButton>
206
+ }
207
+ onIsOpenChange={mockOnIsOpenChange}
208
+ initialIsOpen
209
+ >
210
+ Menu
211
+ </Component>
212
+ <button>Outside</button>
213
+ </div>
214
+ );
215
+
216
+ expect(await screen.findByText("Menu")).toBeInTheDocument();
217
+
218
+ // Click outside the menu
219
+ const otherButton = screen.getByRole("button", { name: "Outside" });
220
+ await user.click(otherButton);
221
+
222
+ // Check that menu closes
223
+ await waitFor(() =>
224
+ expect(screen.queryByText("Menu")).not.toBeInTheDocument()
225
+ );
226
+ expect(mockOnIsOpenChange).toHaveBeenCalled();
227
+ });
228
+
229
+ it("should call onSelectedItemChange when item clicked", async () => {
230
+ const mockOnSelectedItemChange = jest.fn();
231
+ const { user } = render(
232
+ <Component
233
+ triggerElement={
234
+ <MenuToggleButton role="button" aria-label="Open Menu">
235
+ Open
236
+ </MenuToggleButton>
237
+ }
238
+ onSelectedItemChange={mockOnSelectedItemChange}
239
+ >
240
+ <MenuContent>
241
+ <MenuItem id="item-1">Item 1</MenuItem>
242
+ <MenuItem id="item-2">Item 2</MenuItem>
243
+ </MenuContent>
244
+ </Component>
245
+ );
246
+
247
+ // Open Menu
248
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
249
+ await user.click(menuTrigger);
250
+
251
+ // Wait till Menu is fully open
252
+ const menuItem = await screen.findByText("Item 1");
253
+
254
+ // Use click to activate menu item
255
+ await user.click(menuItem);
256
+
257
+ // Check that onSelectedItemChange is called
258
+ await waitFor(() => expect(mockOnSelectedItemChange).toHaveBeenCalled());
259
+ });
260
+
261
+ it("should call onSelectedItemChange when item selected with enter", async () => {
262
+ const mockOnSelectedItemChange = jest.fn();
263
+ const { user } = render(
264
+ <Component
265
+ triggerElement={
266
+ <MenuToggleButton role="button" aria-label="Open Menu">
267
+ Open
268
+ </MenuToggleButton>
269
+ }
270
+ onSelectedItemChange={mockOnSelectedItemChange}
271
+ >
272
+ <MenuContent>
273
+ <MenuItem id="item-1">Item 1</MenuItem>
274
+ <MenuItem id="item-2">Item 2</MenuItem>
275
+ </MenuContent>
276
+ </Component>
277
+ );
278
+
279
+ // Open Menu
280
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
281
+ await user.click(menuTrigger);
282
+
283
+ // Wait till Menu is fully open
284
+ await screen.findByText("Item 1");
285
+
286
+ // Use enter to activate menu item
287
+ await user.keyboard("{arrowdown}{enter}");
288
+
289
+ // Check that onSelectedItemChange is called
290
+ await waitFor(() => expect(mockOnSelectedItemChange).toHaveBeenCalled());
291
+ });
292
+
293
+ it("should call onSelectedItemChange when item selected with space", async () => {
294
+ const mockOnSelectedItemChange = jest.fn();
295
+ const { user } = render(
296
+ <Component
297
+ triggerElement={
298
+ <MenuToggleButton role="button" aria-label="Open Menu">
299
+ Open
300
+ </MenuToggleButton>
301
+ }
302
+ onSelectedItemChange={mockOnSelectedItemChange}
303
+ >
304
+ <MenuContent>
305
+ <MenuItem id="item-1">Item 1</MenuItem>
306
+ <MenuItem id="item-2">Item 2</MenuItem>
307
+ </MenuContent>
308
+ </Component>
309
+ );
310
+
311
+ // Open Menu
312
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
313
+ await user.click(menuTrigger);
314
+
315
+ // Wait till Menu is fully open
316
+ await screen.findByText("Item 1");
317
+
318
+ // Use space to activate menu item
319
+ await user.keyboard("{arrowdown} ");
320
+
321
+ // Check that onSelectedItemChange is called
322
+ await waitFor(() => expect(mockOnSelectedItemChange).toHaveBeenCalled());
323
+ });
324
+
325
+ it("should call onIsOpenChange when menu is opened or closed", async () => {
326
+ const mockOnIsOpenChange = jest.fn();
327
+ const { user } = render(
328
+ <Component
329
+ triggerElement={
330
+ <MenuToggleButton role="button" aria-label="Open Menu">
331
+ Open
332
+ </MenuToggleButton>
333
+ }
334
+ onIsOpenChange={mockOnIsOpenChange}
335
+ >
336
+ <MenuContent>
337
+ <MenuItem id="item-1">Item 1</MenuItem>
338
+ <MenuItem id="item-2">Item 2</MenuItem>
339
+ </MenuContent>
340
+ </Component>
341
+ );
342
+
343
+ // Open menu by clicking trigger
344
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
345
+ await user.click(menuTrigger);
346
+
347
+ // Wait till menu is open
348
+ await screen.findByText("Item 1");
349
+
350
+ // Close menu by clicking trigger
351
+ await user.click(menuTrigger);
352
+
353
+ // Check that onIsOpenChange is called
354
+ await waitFor(() => expect(mockOnIsOpenChange).toHaveBeenCalledTimes(2));
355
+ });
356
+
357
+ it("should call onHighlightedIndexChange when navigating menu with the keyboard", async () => {
358
+ const mockOnHighlightedIndexChange = jest.fn();
359
+ const { user } = render(
360
+ <Component
361
+ triggerElement={
362
+ <MenuToggleButton role="button" aria-label="Open Menu">
363
+ Open
364
+ </MenuToggleButton>
365
+ }
366
+ onHighlightedIndexChange={mockOnHighlightedIndexChange}
367
+ >
368
+ <MenuContent>
369
+ <MenuItem id="item-1">Item 1</MenuItem>
370
+ <MenuItem id="item-2">Item 2</MenuItem>
371
+ </MenuContent>
372
+ </Component>
373
+ );
374
+
375
+ // Open Menu
376
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
377
+ await user.click(menuTrigger);
378
+
379
+ // Wait till Menu is fully open
380
+ await screen.findByText("Item 1");
381
+
382
+ // Use arrowdown to navigate the menu
383
+ await user.keyboard("{arrowdown}");
384
+
385
+ // Check that onHighlightedIndexChange is called
386
+ await waitFor(() =>
387
+ expect(mockOnHighlightedIndexChange).toHaveBeenCalled()
388
+ );
389
+ });
390
+
391
+ it("should accept a custom stateReducer", async () => {
392
+ const mockStateReducer = jest.fn((state, { changes }) => changes);
393
+ const { user } = render(
394
+ <Component
395
+ triggerElement={
396
+ <MenuToggleButton role="button" aria-label="Open Menu">
397
+ Open
398
+ </MenuToggleButton>
399
+ }
400
+ stateReducer={mockStateReducer}
401
+ >
402
+ <MenuContent>
403
+ <MenuItem id="item-1">Item 1</MenuItem>
404
+ <MenuItem id="item-2">Item 2</MenuItem>
405
+ </MenuContent>
406
+ </Component>
407
+ );
408
+
409
+ // Open Menu
410
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
411
+ await user.click(menuTrigger);
412
+
413
+ // Wait till Menu is fully open
414
+ await screen.findByText("Item 1");
415
+
416
+ // Use arrowdown and enter to select item
417
+ await user.keyboard("{arrowdown}{enter}");
418
+
419
+ // Check that stateReducer is called
420
+ await waitFor(() => expect(mockStateReducer).toHaveBeenCalledTimes(4));
421
+ });
422
+ });
423
+ });
@@ -0,0 +1,17 @@
1
+ export const MENU_ROLES = {
2
+ MENU: "menu",
3
+ LIST: "list",
4
+ LISTBOX: "listbox",
5
+ } as const;
6
+
7
+ export const MENU_ITEM_ROLES = {
8
+ MENUITEM: "menuitem",
9
+ LISTITEM: "listitem",
10
+ OPTION: "option",
11
+ RADIO: "menuitemradio",
12
+ CHECKBOX: "menuitemcheckbox",
13
+ } as const;
14
+
15
+ export type TypeMenuRoles = (typeof MENU_ROLES)[keyof typeof MENU_ROLES];
16
+ export type TypeMenuItemRoles =
17
+ (typeof MENU_ITEM_ROLES)[keyof typeof MENU_ITEM_ROLES];