@sproutsocial/seeds-react-menu 0.5.0 → 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 (103) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +15 -0
  3. package/dist/esm/index.js +1486 -440
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/index.d.mts +248 -92
  6. package/dist/index.d.ts +248 -92
  7. package/dist/index.js +1500 -429
  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 +24 -27
  12. package/src/ActionMenu/ActionMenuTypes.ts +8 -7
  13. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +17 -8
  14. package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +8 -8
  15. package/src/Autocomplete/Autocomplete.stories.tsx +38 -3
  16. package/src/Autocomplete/Autocomplete.tsx +15 -80
  17. package/src/Autocomplete/AutocompleteInput.tsx +18 -27
  18. package/src/Autocomplete/AutocompleteTokenInput.tsx +50 -0
  19. package/src/Autocomplete/AutocompleteTypes.ts +25 -12
  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 +90 -8
  24. package/src/MenuContent/MenuContent.stories.tsx +164 -0
  25. package/src/MenuContent/MenuContent.tsx +26 -12
  26. package/src/MenuContent/MenuContentTypes.ts +6 -4
  27. package/src/MenuContent/__tests__/MenuContent.typetest.tsx +8 -8
  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 +16 -16
  32. package/src/MenuGroup/MenuGroup.stories.tsx +248 -0
  33. package/src/MenuGroup/MenuGroup.tsx +8 -7
  34. package/src/MenuGroup/MenuGroupTypes.ts +6 -2
  35. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +6 -6
  36. package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +7 -7
  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 -8
  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 +38 -68
  65. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +8 -6
  66. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +46 -17
  67. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +6 -6
  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 +23 -26
  81. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +7 -5
  82. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +6 -6
  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/hooks/useDownshiftDefaults.tsx +107 -0
  91. package/src/hooks/useItemFiltering.tsx +66 -0
  92. package/src/hooks/useMenuChildren.tsx +197 -128
  93. package/src/index.ts +6 -0
  94. package/src/menuTestingUtils.tsx +21 -10
  95. package/src/reducers/nestedMenuStateReducer.tsx +23 -0
  96. package/src/reducers/useComboboxStateReducer.tsx +26 -8
  97. package/src/reducers/useSelectStateReducer.tsx +24 -6
  98. package/src/storybookUtilities/menu-storybook-components.tsx +5 -3
  99. package/src/types.ts +38 -30
  100. package/src/utils/downshiftDefaults.ts +9 -0
  101. package/src/utils/getMultiSelectOverrides.ts +21 -0
  102. package/src/utils/reduceReducers.ts +19 -0
  103. 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
  });
@@ -5,6 +5,7 @@ import {
5
5
  MenuHeader,
6
6
  MenuFooter,
7
7
  MenuItem,
8
+ type TypeMenuItemProps,
8
9
  } from "../../index";
9
10
 
10
11
  export const AutocompleteTypeTest = () => (
@@ -21,15 +22,15 @@ export const AutocompleteTypeTest = () => (
21
22
  <MenuContent>Test</MenuContent>
22
23
  <MenuFooter>Test</MenuFooter>
23
24
  </Autocomplete>
24
- {/* Valid Autocomplete allows items to be passed using the items prop */}
25
+ {/* Valid Autocomplete allows menuItems to be passed using the menuItems prop */}
25
26
  <Autocomplete
26
27
  menuToggleElement={<>test</>}
27
- items={[{ children: "Item Name", id: "item-1" }]}
28
+ menuItems={[{ children: "Item Name", id: "item-1" }]}
28
29
  />
29
- {/* Valid Autocomplete allows items to be rendered using MenuItemComponent */}
30
+ {/* Valid Autocomplete allows menuItems to be rendered using MenuItemComponent */}
30
31
  <Autocomplete
31
32
  menuToggleElement={<>test</>}
32
- items={[{ children: "Item Name", id: "item-1" }]}
33
+ menuItems={[{ children: "Item Name", id: "item-1" }]}
33
34
  MenuItemComponent={MenuItem}
34
35
  />
35
36
  {/* Valid Autocomplete allows getIsItemVisible function */}
@@ -48,6 +49,27 @@ export const AutocompleteTypeTest = () => (
48
49
  >
49
50
  Test
50
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>
51
73
  {/* @ts-expect-error - menuToggleElement is a required prop */}
52
74
  <Autocomplete>Test</Autocomplete>
53
75
  <Autocomplete
@@ -58,8 +80,8 @@ export const AutocompleteTypeTest = () => (
58
80
  </Autocomplete>
59
81
  {/* @ts-expect-error - when no children or items provided */}
60
82
  <Autocomplete menuToggleElement={<>test</>} />
61
- {/* @ts-expect-error - when both children and items provided */}
62
- <Autocomplete menuToggleElement={<>test</>} items={[]}>
83
+ {/* @ts-expect-error - when both children and menuItems provided */}
84
+ <Autocomplete menuToggleElement={<>test</>} menuItems={[]}>
63
85
  Test
64
86
  </Autocomplete>
65
87
  {/* @ts-expect-error - when MenuItemComponent is passed with children */}
@@ -68,10 +90,13 @@ export const AutocompleteTypeTest = () => (
68
90
  </Autocomplete>
69
91
  <Autocomplete
70
92
  menuToggleElement={<>test</>}
71
- items={[]}
93
+ menuItems={[] as TypeMenuItemProps[]}
72
94
  /* @ts-expect-error - when MenuItemComponent has the wrong type */
73
- MenuItemComponent={MenuFooter}
95
+ MenuItemComponent={true}
96
+ // TODO: Fix TypeChildrenOrItems so this fails
97
+ // MenuItemComponent={MenuFooter}
74
98
  ></Autocomplete>
99
+ {/* Valid Autocomplete rejects invalid props when not multiSelect */}
75
100
  <Autocomplete
76
101
  menuToggleElement={<>test</>}
77
102
  /* @ts-expect-error - isOpen can't be a string */
@@ -87,5 +112,62 @@ export const AutocompleteTypeTest = () => (
87
112
  >
88
113
  Test
89
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>
90
172
  </>
91
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,8 +1,9 @@
1
1
  import type { TypeMenuContentProps } from "./MenuContentTypes";
2
2
  import { StyledMenuContent } from "./styles";
3
- import { useMenuContext } from "../MenuContext";
4
- import { MenuItem } from "../MenuItem/index";
3
+ import { useMenuContentContext } from "../MenuContext";
5
4
  import { mapMenuItems } from "../hooks/useMenuChildren";
5
+ import { useCallback } from "react";
6
+ import type { TypeMenuItemProps } from "../MenuItem";
6
7
 
7
8
  /**
8
9
  * @link https://seeds.sproutsocial.com/components/menu-content/
@@ -27,36 +28,49 @@ import { mapMenuItems } from "../hooks/useMenuChildren";
27
28
  * @see {@link https://seeds.sproutsocial.com/components/menu-group/ | MenuGroup}
28
29
  */
29
30
 
30
- const MenuContent = ({
31
+ const MenuContent = <I extends TypeMenuItemProps = TypeMenuItemProps>({
31
32
  id = "menu_content",
32
33
  children: childrenProp,
33
- items,
34
- MenuItemComponent = MenuItem,
34
+ menuItems,
35
+ MenuItemComponent,
36
+ onKeyDown,
37
+ innerRef = null,
35
38
  ...rest
36
- }: TypeMenuContentProps) => {
37
- const { getToggleButtonProps, getMenuProps, isListbox } = useMenuContext();
39
+ }: TypeMenuContentProps<I>) => {
40
+ const { getToggleButtonProps, getMenuProps, isListbox } =
41
+ useMenuContentContext();
42
+ const toggleButtonProps = getToggleButtonProps?.();
38
43
 
39
44
  const children =
40
- childrenProp || !items || !items.length
45
+ childrenProp || !menuItems || !menuItems.length
41
46
  ? childrenProp
42
- : mapMenuItems({ items, MenuItemComponent });
47
+ : mapMenuItems({ menuItems, MenuItemComponent });
43
48
 
44
49
  if (!children) {
45
50
  return null;
46
51
  }
47
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
+ );
61
+
48
62
  return (
49
63
  <StyledMenuContent
50
64
  autoFocus
51
65
  id={id}
52
66
  tabIndex={0}
53
67
  // apply onKeyDown logic for downshift keyboard accessibility support
54
- onKeyDown={getToggleButtonProps?.()["onKeyDown"] || undefined}
68
+ onKeyDown={handleKeyDown}
55
69
  // apply active descendent updates to container for proper accessibility
56
- aria-activedescendant={getToggleButtonProps?.()["aria-activedescendant"]}
70
+ aria-activedescendant={toggleButtonProps?.["aria-activedescendant"]}
57
71
  {...getMenuProps?.(
58
72
  {
59
- ...(isListbox ? {} : { role: "menu" }),
73
+ ...(isListbox ? { ref: innerRef } : { ref: innerRef, role: "menu" }),
60
74
  },
61
75
  // Suppress ref error for now
62
76
  { suppressRefError: true }
@@ -1,7 +1,9 @@
1
1
  import { type HTMLMotionProps } from "motion/react";
2
+ import type { TypeMenuItemProps } from "../MenuItem";
2
3
  import type { TypeChildrenOrItems } from "../types";
3
4
 
4
- interface TypeMenuContentBaseProps extends HTMLMotionProps<"ul"> {}
5
-
6
- export type TypeMenuContentProps = TypeMenuContentBaseProps &
7
- TypeChildrenOrItems;
5
+ export type TypeMenuContentProps<
6
+ I extends TypeMenuItemProps = TypeMenuItemProps
7
+ > = Omit<HTMLMotionProps<"ul">, "children"> & {
8
+ innerRef?: React.Ref<HTMLUListElement>;
9
+ } & TypeChildrenOrItems<I>;
@@ -1,14 +1,14 @@
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 items prop*/}
8
- <MenuContent items={[{ id: "1", children: "1" }]} />
9
- {/* MenuContent allows items prop with MenuItemComponent*/}
7
+ {/* MenuContent allows menuItems prop*/}
8
+ <MenuContent menuItems={[{ id: "1", children: "1" }]} />
9
+ {/* MenuContent allows menuItems prop with MenuItemComponent*/}
10
10
  <MenuContent
11
- items={[{ id: "1", children: "1" }]}
11
+ menuItems={[{ id: "1", children: "1" }]}
12
12
  MenuItemComponent={MenuItem}
13
13
  />
14
14
  {/* MenuContent allows children with MenuItems */}
@@ -21,12 +21,12 @@ export const MenuContentTypeTest = () => (
21
21
 
22
22
  {/* @ts-expect-error - MenuContent requires children or items */}
23
23
  <MenuContent />
24
- {/* @ts-expect-error - when both children and items provided */}
25
- <MenuContent items={[]}>Test</MenuContent>
24
+ {/* @ts-expect-error - when both children and menuItems provided */}
25
+ <MenuContent menuItems={[]}>Test</MenuContent>
26
26
  {/* @ts-expect-error - when MenuItemComponent is passed with children */}
27
27
  <MenuContent MenuItemComponent={MenuItem}>Test</MenuContent>
28
28
  <MenuContent
29
- items={[]}
29
+ menuItems={[] as TypeMenuItemProps[]}
30
30
  /* @ts-expect-error - when MenuItemComponent has the wrong type */
31
31
  MenuItemComponent={({ text }) => <div>{text}</div>}
32
32
  ></MenuContent>