@sproutsocial/seeds-react-menu 0.4.1 → 0.6.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 (105) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +32 -0
  3. package/dist/esm/index.js +1553 -423
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/index.d.mts +303 -122
  6. package/dist/index.d.ts +303 -122
  7. package/dist/index.js +1561 -405
  8. package/dist/index.js.map +1 -1
  9. package/package.json +4 -1
  10. package/src/ActionMenu/ActionMenu.stories.tsx +426 -0
  11. package/src/ActionMenu/ActionMenu.tsx +30 -26
  12. package/src/ActionMenu/ActionMenuTypes.ts +13 -8
  13. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +17 -30
  14. package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +32 -3
  15. package/src/Autocomplete/Autocomplete.stories.tsx +61 -2
  16. package/src/Autocomplete/Autocomplete.tsx +15 -74
  17. package/src/Autocomplete/AutocompleteInput.tsx +18 -27
  18. package/src/Autocomplete/AutocompleteTokenInput.tsx +50 -0
  19. package/src/Autocomplete/AutocompleteTypes.ts +27 -11
  20. package/src/Autocomplete/MultiAutocomplete.tsx +189 -0
  21. package/src/Autocomplete/SingleAutocomplete.tsx +74 -0
  22. package/src/Autocomplete/__tests__/Autocomplete.test.tsx +171 -3
  23. package/src/Autocomplete/__tests__/Autocomplete.typetest.tsx +116 -3
  24. package/src/MenuContent/MenuContent.stories.tsx +164 -0
  25. package/src/MenuContent/MenuContent.tsx +35 -8
  26. package/src/MenuContent/MenuContentTypes.ts +7 -5
  27. package/src/MenuContent/__tests__/MenuContent.typetest.tsx +18 -2
  28. package/src/MenuContext.tsx +191 -37
  29. package/src/MenuFooter/MenuFooter.stories.tsx +202 -0
  30. package/src/MenuFooter/__tests__/MenuFooter.test.tsx +16 -11
  31. package/src/MenuGroup/MenuGroup.feature +21 -21
  32. package/src/MenuGroup/MenuGroup.stories.tsx +248 -0
  33. package/src/MenuGroup/MenuGroup.tsx +9 -7
  34. package/src/MenuGroup/MenuGroupTypes.ts +8 -6
  35. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +6 -6
  36. package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +23 -4
  37. package/src/MenuGroup/styles.tsx +4 -2
  38. package/src/MenuHeader/MenuHeader.stories.tsx +276 -0
  39. package/src/MenuHeader/MenuHeader.tsx +3 -1
  40. package/src/MenuHeader/__tests__/MenuHeader.test.tsx +23 -13
  41. package/src/MenuHeader/styles.tsx +18 -14
  42. package/src/MenuItem/MenuItem.stories.tsx +507 -0
  43. package/src/MenuItem/MenuItem.tsx +18 -9
  44. package/src/MenuItem/MenuItemTypes.ts +9 -4
  45. package/src/MenuItem/__tests__/MenuItem.test.tsx +6 -6
  46. package/src/MenuItem/styles.tsx +21 -5
  47. package/src/MenuItem/useOptionProps.tsx +10 -8
  48. package/src/MenuLabel.tsx +2 -2
  49. package/src/MenuRoot/MenuRoot.tsx +37 -23
  50. package/src/MenuRoot/__tests__/MenuRoot.test.tsx +59 -32
  51. package/src/MenuSearchInput/MenuSearchInput.tsx +63 -0
  52. package/src/MenuSearchInput/__tests__/MenuSearchInput.test.tsx +157 -0
  53. package/src/MenuSearchInput/__tests__/MenuSearchInput.typetest.tsx +31 -0
  54. package/src/MenuSearchInput/index.ts +1 -0
  55. package/src/MenuSearchTokenInput/MenuSearchTokenInput.tsx +98 -0
  56. package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.test.tsx +247 -0
  57. package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.typetest.tsx +33 -0
  58. package/src/MenuSearchTokenInput/index.ts +1 -0
  59. package/src/MenuToggleButton/MenuToggleButton.stories.tsx +72 -0
  60. package/src/{MenuToggleButton.tsx → MenuToggleButton/MenuToggleButton.tsx} +6 -5
  61. package/src/MenuToggleButton/index.ts +1 -0
  62. package/src/MenuTokenInput.tsx +104 -0
  63. package/src/MultiSelectMenu/MultiSelectMenu.stories.tsx +214 -0
  64. package/src/MultiSelectMenu/MultiSelectMenu.tsx +44 -67
  65. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +9 -5
  66. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +46 -60
  67. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +29 -2
  68. package/src/NestedMenu/NestedMenu.feature +109 -0
  69. package/src/NestedMenu/NestedMenu.stories.tsx +157 -0
  70. package/src/NestedMenu/NestedMenu.tsx +102 -0
  71. package/src/NestedMenu/NestedMenuContent.tsx +71 -0
  72. package/src/NestedMenu/NestedMenuContext.tsx +56 -0
  73. package/src/NestedMenu/NestedMenuHeader.tsx +25 -0
  74. package/src/NestedMenu/NestedMenuItem.tsx +37 -0
  75. package/src/NestedMenu/NestedMenuTypes.ts +47 -0
  76. package/src/NestedMenu/__tests__/NestedMenu.test.tsx +31 -0
  77. package/src/NestedMenu/__tests__/NestedMenu.typetest.tsx +134 -0
  78. package/src/NestedMenu/index.ts +5 -0
  79. package/src/SingleSelectMenu/SingleSelectMenu.stories.tsx +227 -0
  80. package/src/SingleSelectMenu/SingleSelectMenu.tsx +29 -25
  81. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +9 -5
  82. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +32 -2
  83. package/src/SubmenuItem/SubmenuItem.feature +82 -0
  84. package/src/SubmenuItem/SubmenuItem.stories.tsx +163 -0
  85. package/src/SubmenuItem/SubmenuItem.tsx +254 -0
  86. package/src/SubmenuItem/SubmenuItemTypes.ts +24 -0
  87. package/src/SubmenuItem/__tests__/SubmenuItem.test.tsx +3 -0
  88. package/src/SubmenuItem/__tests__/SubmenuItem.typetest.tsx +112 -0
  89. package/src/SubmenuItem/index.ts +2 -0
  90. package/src/__tests__/Menu.test.tsx +38 -0
  91. package/src/hooks/useDownshiftDefaults.tsx +107 -0
  92. package/src/hooks/useItemFiltering.tsx +66 -0
  93. package/src/hooks/useMenuChildren.tsx +279 -0
  94. package/src/index.ts +7 -1
  95. package/src/menuTestingUtils.tsx +21 -10
  96. package/src/reducers/nestedMenuStateReducer.tsx +23 -0
  97. package/src/reducers/useComboboxStateReducer.tsx +26 -8
  98. package/src/reducers/useSelectStateReducer.tsx +24 -6
  99. package/src/storybookUtilities/menu-storybook-components.tsx +5 -3
  100. package/src/types.ts +38 -5
  101. package/src/utils/downshiftDefaults.ts +9 -0
  102. package/src/utils/getMultiSelectOverrides.ts +21 -0
  103. package/src/utils/reduceReducers.ts +19 -0
  104. package/src/hooks/useItemsFromChildren.tsx +0 -131
  105. package/src/utils/itemToString.ts +0 -5
@@ -5,9 +5,10 @@ import {
5
5
  waitFor,
6
6
  act,
7
7
  } from "@sproutsocial/seeds-react-testing-library";
8
- import { MenuContent, MenuItem } from "../../index";
8
+ import { MenuContent, MenuItem, type TypeMenuRootProps } from "../../index";
9
9
  import { Autocomplete } from "../Autocomplete";
10
10
  import { AutocompleteInput } from "../AutocompleteInput";
11
+ import { AutocompleteTokenInput } from "../AutocompleteTokenInput";
11
12
  import type { TypeAutocompleteProps } from "../AutocompleteTypes";
12
13
 
13
14
  const menuItems = [
@@ -74,6 +75,7 @@ describe("Autocomplete Component", () => {
74
75
  const suggestion = screen.getByRole("option", { name: menuItems[0].label });
75
76
  await act(() => user.click(suggestion));
76
77
  await waitFor(() => expect(inputElement.value).toBe(menuItems[0].label));
78
+ await act(async () => await user.click(inputElement));
77
79
  expect(suggestion).toHaveAttribute("aria-selected", "true");
78
80
  });
79
81
 
@@ -145,7 +147,8 @@ describe("Autocomplete Component", () => {
145
147
 
146
148
  it("should use the custom filter function when getIsItemVisible is passed", async () => {
147
149
  const customFilter: TypeAutocompleteProps["getIsItemVisible"] = jest.fn(
148
- (item, inputValue) => itemToString(item).includes(inputValue)
150
+ (item, inputValue) =>
151
+ itemToString(item).toLowerCase().startsWith(inputValue.toLowerCase())
149
152
  );
150
153
  const { user } = render(
151
154
  <Autocomplete
@@ -156,11 +159,176 @@ describe("Autocomplete Component", () => {
156
159
  const inputElement = screen.getByRole("combobox");
157
160
  await act(async () => {
158
161
  await user.click(inputElement);
162
+ await user.type(inputElement, "item 0");
163
+ });
164
+
165
+ await waitFor(() => expect(screen.queryAllByRole("option").length).toBe(1));
166
+
167
+ await act(async () => {
168
+ await user.clear(inputElement);
159
169
  await user.type(inputElement, "0");
160
170
  });
161
171
 
162
- await waitFor(() => expect(screen.getAllByRole("option").length).toBe(1));
172
+ await waitFor(() => expect(screen.queryAllByRole("option").length).toBe(0));
163
173
 
164
174
  expect(customFilter).toHaveBeenCalled();
165
175
  });
176
+
177
+ describe("in multiSelect mode", () => {
178
+ it("should render multiple selected items", () => {
179
+ render(
180
+ <Autocomplete
181
+ {...defaultAutocompleteProps}
182
+ menuToggleElement={<AutocompleteTokenInput />}
183
+ initialSelectedItems={[menuItems[0], menuItems[1]]}
184
+ multiSelect
185
+ />
186
+ );
187
+ const selectedItems = screen.getAllByRole("button");
188
+ expect(selectedItems.length).toBe(2);
189
+ expect(selectedItems[0]).toHaveTextContent(menuItems[0].label);
190
+ expect(selectedItems[1]).toHaveTextContent(menuItems[1].label);
191
+ });
192
+
193
+ it("should add an item to the selected items list", async () => {
194
+ const { user } = render(
195
+ <Autocomplete
196
+ {...defaultAutocompleteProps}
197
+ menuToggleElement={<AutocompleteTokenInput />}
198
+ initialSelectedItems={[menuItems[0]]}
199
+ multiSelect
200
+ />
201
+ );
202
+ const inputElement = screen.getByRole("combobox");
203
+ await act(async () => {
204
+ await user.click(inputElement);
205
+ await user.type(inputElement, "item 1");
206
+ });
207
+ const suggestion = screen.getByRole("option", {
208
+ name: menuItems[1].label,
209
+ });
210
+ await act(() => user.click(suggestion));
211
+ const selectedItems = screen.getAllByRole("button");
212
+ expect(selectedItems.length).toBe(2);
213
+ expect(selectedItems[1]).toHaveTextContent(menuItems[1].label);
214
+ });
215
+
216
+ it("should remove an item from the selected items list", async () => {
217
+ const { user } = render(
218
+ <Autocomplete
219
+ {...defaultAutocompleteProps}
220
+ menuToggleElement={<AutocompleteTokenInput />}
221
+ initialSelectedItems={[menuItems[0], menuItems[1]]}
222
+ multiSelect
223
+ />
224
+ );
225
+ const token = screen.getByRole("button", {
226
+ name: menuItems[0].label,
227
+ });
228
+ await act(() => user.click(token));
229
+ const selectedItems = screen.getAllByRole("button");
230
+ expect(selectedItems.length).toBe(1);
231
+ expect(selectedItems[0]).toHaveTextContent(menuItems[1].label);
232
+ });
233
+
234
+ it("should call onSelectedItemsChange when an item is added", async () => {
235
+ const handleChange = jest.fn();
236
+ const { user } = render(
237
+ <Autocomplete
238
+ {...defaultAutocompleteProps}
239
+ menuToggleElement={<AutocompleteTokenInput />}
240
+ onSelectedItemsChange={handleChange}
241
+ multiSelect
242
+ />
243
+ );
244
+
245
+ const testOption = menuItems[1];
246
+ const inputElement = screen.getByRole("combobox");
247
+ await act(async () => {
248
+ await user.click(inputElement);
249
+ await user.type(inputElement, testOption.label);
250
+ });
251
+
252
+ const suggestion = screen.getByRole("option", {
253
+ name: testOption.label,
254
+ });
255
+ await act(() => user.click(suggestion));
256
+ await waitFor(() => expect(handleChange).toHaveBeenCalled());
257
+ });
258
+
259
+ it("should call onSelectedItemsChange when an item is removed", async () => {
260
+ const testOption = menuItems[0];
261
+ const handleChange = jest.fn();
262
+ const { user } = render(
263
+ <Autocomplete
264
+ {...defaultAutocompleteProps}
265
+ menuToggleElement={<AutocompleteTokenInput />}
266
+ initialSelectedItems={[testOption]}
267
+ onSelectedItemsChange={handleChange}
268
+ multiSelect
269
+ />
270
+ );
271
+
272
+ const token = screen.getByRole("button", {
273
+ name: testOption.label,
274
+ });
275
+ await act(() => user.click(token));
276
+ await waitFor(() => expect(handleChange).toHaveBeenCalled());
277
+ });
278
+
279
+ it("hides selected items in the suggestions list", async () => {
280
+ const testOption = menuItems[0];
281
+ const { user } = render(
282
+ <Autocomplete
283
+ {...defaultAutocompleteProps}
284
+ menuToggleElement={<AutocompleteTokenInput />}
285
+ initialSelectedItems={[testOption]}
286
+ multiSelect
287
+ />
288
+ );
289
+
290
+ const inputElement = screen.getByRole("combobox");
291
+ await act(async () => {
292
+ await user.click(inputElement);
293
+ await user.type(inputElement, "item");
294
+ });
295
+
296
+ waitFor(() =>
297
+ expect(screen.getAllByRole("option")).toHaveLength(menuItems.length - 1)
298
+ );
299
+ expect(
300
+ screen.queryByRole("option", {
301
+ name: testOption.label,
302
+ })
303
+ ).not.toBeInTheDocument();
304
+ });
305
+
306
+ it("shows selected items in the suggestions list when showSelectedItemsInDropdown is true", async () => {
307
+ const testOption = menuItems[0];
308
+ const { user } = render(
309
+ <Autocomplete
310
+ {...defaultAutocompleteProps}
311
+ menuToggleElement={<AutocompleteTokenInput />}
312
+ initialSelectedItems={[testOption]}
313
+ multiSelect
314
+ showSelectedItemsInDropdown
315
+ />
316
+ );
317
+
318
+ const inputElement = screen.getByRole("combobox");
319
+ await act(async () => {
320
+ await user.click(inputElement);
321
+ await user.type(inputElement, "item");
322
+ });
323
+
324
+ waitFor(() =>
325
+ expect(screen.getAllByRole("option")).toHaveLength(menuItems.length)
326
+ );
327
+ expect(
328
+ screen.getByRole("option", {
329
+ name: testOption.label,
330
+ })
331
+ ).toBeInTheDocument();
332
+ });
333
+ });
166
334
  });
@@ -1,5 +1,12 @@
1
1
  import * as React from "react";
2
- import { Autocomplete, MenuContent, MenuHeader, MenuFooter } from "../../index";
2
+ import {
3
+ Autocomplete,
4
+ MenuContent,
5
+ MenuHeader,
6
+ MenuFooter,
7
+ MenuItem,
8
+ type TypeMenuItemProps,
9
+ } from "../../index";
3
10
 
4
11
  export const AutocompleteTypeTest = () => (
5
12
  <>
@@ -15,6 +22,17 @@ export const AutocompleteTypeTest = () => (
15
22
  <MenuContent>Test</MenuContent>
16
23
  <MenuFooter>Test</MenuFooter>
17
24
  </Autocomplete>
25
+ {/* Valid Autocomplete allows menuItems to be passed using the menuItems prop */}
26
+ <Autocomplete
27
+ menuToggleElement={<>test</>}
28
+ menuItems={[{ children: "Item Name", id: "item-1" }]}
29
+ />
30
+ {/* Valid Autocomplete allows menuItems to be rendered using MenuItemComponent */}
31
+ <Autocomplete
32
+ menuToggleElement={<>test</>}
33
+ menuItems={[{ children: "Item Name", id: "item-1" }]}
34
+ MenuItemComponent={MenuItem}
35
+ />
18
36
  {/* Valid Autocomplete allows getIsItemVisible function */}
19
37
  <Autocomplete menuToggleElement={<>test</>} getIsItemVisible={() => true}>
20
38
  Test
@@ -31,6 +49,27 @@ export const AutocompleteTypeTest = () => (
31
49
  >
32
50
  Test
33
51
  </Autocomplete>
52
+ {/* Valid Autocomplete allows multiSelect false */}
53
+ <Autocomplete menuToggleElement={<>test</>} multiSelect={false}>
54
+ Test
55
+ </Autocomplete>
56
+ {/* Valid Autocomplete allows multiSelect true */}
57
+ <Autocomplete menuToggleElement={<>test</>} multiSelect={true}>
58
+ Test
59
+ </Autocomplete>
60
+ {/* Valid Autocomplete allows multiSelect with valid multiSelect props */}
61
+ <Autocomplete
62
+ menuToggleElement={<>test</>}
63
+ multiSelect
64
+ showSelectedItemsInDropdown
65
+ onSelectedItemsChange={() => {}}
66
+ selectedItems={[]}
67
+ multiSelectStateReducer={(c) => c}
68
+ onMultiSelectStateChange={() => {}}
69
+ getA11yMultiStatusMessage={() => ""}
70
+ >
71
+ Test
72
+ </Autocomplete>
34
73
  {/* @ts-expect-error - menuToggleElement is a required prop */}
35
74
  <Autocomplete>Test</Autocomplete>
36
75
  <Autocomplete
@@ -39,8 +78,25 @@ export const AutocompleteTypeTest = () => (
39
78
  >
40
79
  Test
41
80
  </Autocomplete>
42
- {/* @ts-expect-error - when no children provided */}
43
- <Autocomplete menuToggleElement="test" />
81
+ {/* @ts-expect-error - when no children or items provided */}
82
+ <Autocomplete menuToggleElement={<>test</>} />
83
+ {/* @ts-expect-error - when both children and menuItems provided */}
84
+ <Autocomplete menuToggleElement={<>test</>} menuItems={[]}>
85
+ Test
86
+ </Autocomplete>
87
+ {/* @ts-expect-error - when MenuItemComponent is passed with children */}
88
+ <Autocomplete menuToggleElement={<>test</>} MenuItemComponent={MenuItem}>
89
+ Test
90
+ </Autocomplete>
91
+ <Autocomplete
92
+ menuToggleElement={<>test</>}
93
+ menuItems={[] as TypeMenuItemProps[]}
94
+ /* @ts-expect-error - when MenuItemComponent has the wrong type */
95
+ MenuItemComponent={true}
96
+ // TODO: Fix TypeChildrenOrItems so this fails
97
+ // MenuItemComponent={MenuFooter}
98
+ ></Autocomplete>
99
+ {/* Valid Autocomplete rejects invalid props when not multiSelect */}
44
100
  <Autocomplete
45
101
  menuToggleElement={<>test</>}
46
102
  /* @ts-expect-error - isOpen can't be a string */
@@ -56,5 +112,62 @@ export const AutocompleteTypeTest = () => (
56
112
  >
57
113
  Test
58
114
  </Autocomplete>
115
+ <Autocomplete
116
+ menuToggleElement={<>test</>}
117
+ multiSelect={false}
118
+ /* @ts-expect-error - onSelectedItemsChange is not valid for multiSelect false */
119
+ onSelectedItemsChange={() => {}}
120
+ >
121
+ Test
122
+ </Autocomplete>
123
+ <Autocomplete
124
+ menuToggleElement={<>test</>}
125
+ multiSelect={false}
126
+ /* @ts-expect-error - selectedItems is not valid for multiSelect false */
127
+ selectedItems={[]}
128
+ >
129
+ Test
130
+ </Autocomplete>
131
+ <Autocomplete
132
+ menuToggleElement={<>test</>}
133
+ multiSelect={false}
134
+ /* @ts-expect-error - multiSelectStateReducer is not valid for multiSelect false */
135
+ multiSelectStateReducer={(c) => c}
136
+ >
137
+ Test
138
+ </Autocomplete>
139
+ <Autocomplete
140
+ menuToggleElement={<>test</>}
141
+ multiSelect={false}
142
+ /* @ts-expect-error - onMultiSelectStateChange is not valid for multiSelect false */
143
+ onMultiSelectStateChange={() => {}}
144
+ >
145
+ Test
146
+ </Autocomplete>
147
+ <Autocomplete
148
+ menuToggleElement={<>test</>}
149
+ multiSelect={false}
150
+ /* @ts-expect-error - getA11yMultiStatusMessage is not valid for multiSelect false */
151
+ getA11yMultiStatusMessage={() => ""}
152
+ >
153
+ Test
154
+ </Autocomplete>
155
+ {/* Valid Autocomplete rejects invalid props for multiSelect */}
156
+ <Autocomplete
157
+ menuToggleElement={<>test</>}
158
+ multiSelect
159
+ /* @ts-expect-error - showSelectedItemsInDropdown can't be a string */
160
+ showSelectedItemsInDropdown="true"
161
+ /* @ts-expect-error - onSelectedItemsChange can't be a string */
162
+ onSelectedItemsChange="test"
163
+ /* @ts-expect-error - selectedItems can't null */
164
+ selectedItems={null}
165
+ /* @ts-expect-error - multiSelectStateReducer must have a return value */
166
+ multiSelectStateReducer={() => {}}
167
+ /* @ts-expect-error - onMultiSelectStateChange can't be a string */
168
+ onMultiSelectStateChange="test"
169
+ >
170
+ Test
171
+ </Autocomplete>
59
172
  </>
60
173
  );
@@ -0,0 +1,164 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Box } from "@sproutsocial/seeds-react-box";
3
+ import { Button } from "@sproutsocial/seeds-react-button";
4
+ import { Icon } from "@sproutsocial/seeds-react-icon";
5
+ import { Link } from "@sproutsocial/seeds-react-link";
6
+ import { Text } from "@sproutsocial/seeds-react-text";
7
+ import { VisuallyHidden } from "@sproutsocial/seeds-react-visually-hidden";
8
+
9
+ import { StorybookMenuToggleButton } from "../storybookUtilities/menu-storybook-components";
10
+ import {
11
+ MenuHeader,
12
+ MenuLabel,
13
+ MenuContent,
14
+ MenuItem,
15
+ SingleSelectMenu,
16
+ MenuFooter,
17
+ MenuGroup,
18
+ } from "../index";
19
+
20
+ const meta: Meta<typeof MenuContent> = {
21
+ title: "Under Development/Primitives/MenuContent",
22
+ component: MenuContent,
23
+ };
24
+
25
+ export default meta;
26
+ type Story = StoryObj<typeof MenuContent>;
27
+
28
+ interface TypeBook {
29
+ id: string;
30
+ author: string;
31
+ title: string;
32
+ }
33
+
34
+ const books: TypeBook[] = [
35
+ { id: "book-1", author: "Harper Lee", title: "To Kill a Mockingbird" },
36
+ { id: "book-2", author: "Lev Tolstoy", title: "War and Peace" },
37
+ { id: "book-3", author: "Fyodor Dostoyevsy", title: "The Idiot" },
38
+ { id: "book-4", author: "Oscar Wilde", title: "A Picture of Dorian Gray" },
39
+ { id: "book-5", author: "George Orwell", title: "1984" },
40
+ { id: "book-6", author: "Jane Austen", title: "Pride and Prejudice" },
41
+ { id: "book-7", author: "Marcus Aurelius", title: "Meditations" },
42
+ {
43
+ id: "book-8",
44
+ author: "Fyodor Dostoevsky",
45
+ title: "The Brothers Karamazov",
46
+ },
47
+ { id: "book-9", author: "Lev Tolstoy", title: "Anna Karenina" },
48
+ {
49
+ id: "book-10",
50
+ author: "Fyodor Dostoevsky",
51
+ title: "Crime and Punishment",
52
+ },
53
+ ];
54
+
55
+ export const MenuContentStory: Story = {
56
+ name: "Standard Content",
57
+ args: {},
58
+ render: function MenuContentStory() {
59
+ return (
60
+ <Box display="flex" gap="16px" flexDirection="column" maxWidth={300}>
61
+ <SingleSelectMenu
62
+ defaultIsOpen
63
+ menuToggleElement={
64
+ <>
65
+ <VisuallyHidden>
66
+ <MenuLabel>Select your favorite book:</MenuLabel>
67
+ </VisuallyHidden>
68
+ <StorybookMenuToggleButton />
69
+ </>
70
+ }
71
+ >
72
+ {/* Header */}
73
+ <MenuHeader
74
+ leftAction={
75
+ <Button
76
+ width="24px"
77
+ height="24px"
78
+ display="flex"
79
+ justifyContent="center"
80
+ alignItems="center"
81
+ >
82
+ <Icon name="arrow-left" />
83
+ </Button>
84
+ }
85
+ rightAction={<Link href="#">Help</Link>}
86
+ title="Sprout menu"
87
+ />
88
+ {/* All Menu List */}
89
+ <MenuContent>
90
+ <MenuGroup id="books">
91
+ {books.map((book) => (
92
+ <MenuItem key={book.id} id={book.id}>
93
+ {book.title}
94
+ </MenuItem>
95
+ ))}
96
+ </MenuGroup>
97
+ </MenuContent>
98
+ {/* Footer */}
99
+ <MenuFooter display="flex" alignItems="right" justifyContent="right">
100
+ <Button display="flex" justifyContent="center" alignItems="center">
101
+ <Text color="text.body" mr={200}>
102
+ Do Something
103
+ </Text>
104
+ <Icon name="arrow-right" />
105
+ </Button>
106
+ </MenuFooter>
107
+ </SingleSelectMenu>
108
+ </Box>
109
+ );
110
+ },
111
+ };
112
+
113
+ export const MenuContentWithItemsAsProps: Story = {
114
+ name: "With Items as Props",
115
+ render: function MenuContentWithItemsAsProps() {
116
+ const menuItems = books.map((book) => ({
117
+ id: book.id,
118
+ children: book.title,
119
+ }));
120
+ return (
121
+ <Box display="flex" gap="16px" flexDirection="column" maxWidth={300}>
122
+ <SingleSelectMenu
123
+ defaultIsOpen
124
+ menuToggleElement={
125
+ <>
126
+ <VisuallyHidden>
127
+ <MenuLabel>Select your favorite book:</MenuLabel>
128
+ </VisuallyHidden>
129
+ <StorybookMenuToggleButton />
130
+ </>
131
+ }
132
+ >
133
+ {/* Header */}
134
+ <MenuHeader
135
+ leftAction={
136
+ <Button
137
+ width="24px"
138
+ height="24px"
139
+ display="flex"
140
+ justifyContent="center"
141
+ alignItems="center"
142
+ >
143
+ <Icon name="arrow-left" />
144
+ </Button>
145
+ }
146
+ rightAction={<Link href="#">Help</Link>}
147
+ title="Sprout menu"
148
+ />
149
+ {/* All Menu List */}
150
+ <MenuContent menuItems={menuItems} />
151
+ {/* Footer */}
152
+ <MenuFooter display="flex" alignItems="right" justifyContent="right">
153
+ <Button display="flex" justifyContent="center" alignItems="center">
154
+ <Text color="text.body" mr={200}>
155
+ Do Something
156
+ </Text>
157
+ <Icon name="arrow-right" />
158
+ </Button>
159
+ </MenuFooter>
160
+ </SingleSelectMenu>
161
+ </Box>
162
+ );
163
+ },
164
+ };
@@ -1,6 +1,9 @@
1
1
  import type { TypeMenuContentProps } from "./MenuContentTypes";
2
2
  import { StyledMenuContent } from "./styles";
3
- import { useMenuContext } from "../MenuContext";
3
+ import { useMenuContentContext } from "../MenuContext";
4
+ import { mapMenuItems } from "../hooks/useMenuChildren";
5
+ import { useCallback } from "react";
6
+ import type { TypeMenuItemProps } from "../MenuItem";
4
7
 
5
8
  /**
6
9
  * @link https://seeds.sproutsocial.com/components/menu-content/
@@ -25,12 +28,36 @@ import { useMenuContext } from "../MenuContext";
25
28
  * @see {@link https://seeds.sproutsocial.com/components/menu-group/ | MenuGroup}
26
29
  */
27
30
 
28
- const MenuContent = ({
31
+ const MenuContent = <I extends TypeMenuItemProps = TypeMenuItemProps>({
29
32
  id = "menu_content",
30
- children,
33
+ children: childrenProp,
34
+ menuItems,
35
+ MenuItemComponent,
36
+ onKeyDown,
37
+ innerRef = null,
31
38
  ...rest
32
- }: TypeMenuContentProps) => {
33
- const { getToggleButtonProps, getMenuProps, isListbox } = useMenuContext();
39
+ }: TypeMenuContentProps<I>) => {
40
+ const { getToggleButtonProps, getMenuProps, isListbox } =
41
+ useMenuContentContext();
42
+ const toggleButtonProps = getToggleButtonProps?.();
43
+
44
+ const children =
45
+ childrenProp || !menuItems || !menuItems.length
46
+ ? childrenProp
47
+ : mapMenuItems({ menuItems, MenuItemComponent });
48
+
49
+ if (!children) {
50
+ return null;
51
+ }
52
+
53
+ // Memoize the onKeyDown handler to prevent unnecessary re-renders
54
+ const handleKeyDown = useCallback(
55
+ (e) => {
56
+ toggleButtonProps?.["onKeyDown"]?.(e);
57
+ onKeyDown?.(e);
58
+ },
59
+ [onKeyDown, toggleButtonProps?.["onKeyDown"]]
60
+ );
34
61
 
35
62
  return (
36
63
  <StyledMenuContent
@@ -38,12 +65,12 @@ const MenuContent = ({
38
65
  id={id}
39
66
  tabIndex={0}
40
67
  // apply onKeyDown logic for downshift keyboard accessibility support
41
- onKeyDown={getToggleButtonProps?.()["onKeyDown"] || undefined}
68
+ onKeyDown={handleKeyDown}
42
69
  // apply active descendent updates to container for proper accessibility
43
- aria-activedescendant={getToggleButtonProps?.()["aria-activedescendant"]}
70
+ aria-activedescendant={toggleButtonProps?.["aria-activedescendant"]}
44
71
  {...getMenuProps?.(
45
72
  {
46
- ...(isListbox ? {} : { role: "menu" }),
73
+ ...(isListbox ? { ref: innerRef } : { ref: innerRef, role: "menu" }),
47
74
  },
48
75
  // Suppress ref error for now
49
76
  { suppressRefError: true }
@@ -1,7 +1,9 @@
1
- import React from "react";
2
1
  import { type HTMLMotionProps } from "motion/react";
3
- import type { ReactNode, RefObject } from "react";
2
+ import type { TypeMenuItemProps } from "../MenuItem";
3
+ import type { TypeChildrenOrItems } from "../types";
4
4
 
5
- export interface TypeMenuContentProps extends HTMLMotionProps<"ul"> {
6
- children: ReactNode;
7
- }
5
+ export type TypeMenuContentProps<
6
+ I extends TypeMenuItemProps = TypeMenuItemProps
7
+ > = Omit<HTMLMotionProps<"ul">, "children"> & {
8
+ innerRef?: React.Ref<HTMLUListElement>;
9
+ } & TypeChildrenOrItems<I>;
@@ -1,9 +1,16 @@
1
- import { MenuContent, MenuItem } from "../../index";
1
+ import { MenuContent, MenuItem, type TypeMenuItemProps } from "../../index";
2
2
 
3
3
  export const MenuContentTypeTest = () => (
4
4
  <>
5
5
  {/* MenuContent allows children */}
6
6
  <MenuContent>Test</MenuContent>
7
+ {/* MenuContent allows menuItems prop*/}
8
+ <MenuContent menuItems={[{ id: "1", children: "1" }]} />
9
+ {/* MenuContent allows menuItems prop with MenuItemComponent*/}
10
+ <MenuContent
11
+ menuItems={[{ id: "1", children: "1" }]}
12
+ MenuItemComponent={MenuItem}
13
+ />
7
14
  {/* MenuContent allows children with MenuItems */}
8
15
  <MenuContent>
9
16
  <MenuItem id="1">Test 1</MenuItem>
@@ -12,7 +19,16 @@ export const MenuContentTypeTest = () => (
12
19
  {/* MenuContent accepts ul props */}
13
20
  <MenuContent aria-label="test label">Test</MenuContent>
14
21
 
15
- {/* @ts-expect-error - MenuContent requires children */}
22
+ {/* @ts-expect-error - MenuContent requires children or items */}
16
23
  <MenuContent />
24
+ {/* @ts-expect-error - when both children and menuItems provided */}
25
+ <MenuContent menuItems={[]}>Test</MenuContent>
26
+ {/* @ts-expect-error - when MenuItemComponent is passed with children */}
27
+ <MenuContent MenuItemComponent={MenuItem}>Test</MenuContent>
28
+ <MenuContent
29
+ menuItems={[] as TypeMenuItemProps[]}
30
+ /* @ts-expect-error - when MenuItemComponent has the wrong type */
31
+ MenuItemComponent={({ text }) => <div>{text}</div>}
32
+ ></MenuContent>
17
33
  </>
18
34
  );