@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
@@ -11,6 +11,7 @@ import {
11
11
  screen,
12
12
  cleanup,
13
13
  waitFor,
14
+ act,
14
15
  } from "@sproutsocial/seeds-react-testing-library";
15
16
 
16
17
  const menuItems = [
@@ -25,49 +26,6 @@ describe("MultiSelectMenu", () => {
25
26
  cleanup();
26
27
  });
27
28
 
28
- it("should render the MenuItems when provided as children", async () => {
29
- const { user } = render(
30
- <MultiSelectMenu
31
- menuToggleElement={
32
- <MenuToggleButton aria-label="testable menu" role="button">
33
- Testable Menu
34
- </MenuToggleButton>
35
- }
36
- >
37
- <MenuContent>
38
- <MenuGroup id="group">
39
- {menuItems.map((item) => (
40
- <MenuItem key={item.id} id={item.id} label={item.label}>
41
- {item.label}
42
- </MenuItem>
43
- ))}
44
- </MenuGroup>
45
- </MenuContent>
46
- </MultiSelectMenu>
47
- );
48
-
49
- // Check that the menu is closed
50
- expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
51
-
52
- const toggleButton = screen.getByRole("button", { name: "testable menu" });
53
-
54
- // Open Menu
55
- await user.click(toggleButton);
56
-
57
- await waitFor(() => {
58
- // Check that the menu is open
59
- expect(screen.getByRole("listbox")).toBeInTheDocument();
60
- // Check that the menu items are present
61
- expect(
62
- screen.getByRole("option", { name: "Item 1" })
63
- ).toBeInTheDocument();
64
- expect(
65
- // Check that the menu items are present
66
- screen.getByRole("option", { name: "Item 2" })
67
- ).toBeInTheDocument();
68
- });
69
- });
70
-
71
29
  it("should allow for multiple items to be selected", async () => {
72
30
  const { user } = render(
73
31
  <MultiSelectMenu
@@ -96,7 +54,9 @@ describe("MultiSelectMenu", () => {
96
54
 
97
55
  const toggleButton = screen.getByRole("button", { name: "testable menu" });
98
56
 
99
- await user.click(toggleButton);
57
+ await act(async () => {
58
+ await user.click(toggleButton);
59
+ });
100
60
 
101
61
  const item1 = screen.getByRole("option", { name: "Item 1" });
102
62
  const item2 = screen.getByRole("option", { name: "Item 2" });
@@ -107,7 +67,9 @@ describe("MultiSelectMenu", () => {
107
67
  });
108
68
 
109
69
  // Select first item
110
- await user.click(item1);
70
+ await act(async () => {
71
+ await user.click(item1);
72
+ });
111
73
 
112
74
  await waitFor(() => {
113
75
  // Check that the first item is selected
@@ -115,7 +77,9 @@ describe("MultiSelectMenu", () => {
115
77
  });
116
78
 
117
79
  // Select second item
118
- await user.click(item2);
80
+ await act(async () => {
81
+ await user.click(item2);
82
+ });
119
83
 
120
84
  await waitFor(() => {
121
85
  // Check that the second item is selected
@@ -155,7 +119,9 @@ describe("MultiSelectMenu", () => {
155
119
  const toggleButton = screen.getByRole("button", { name: "testable menu" });
156
120
 
157
121
  // Open Menu
158
- await user.click(toggleButton);
122
+ await act(async () => {
123
+ await user.click(toggleButton);
124
+ });
159
125
 
160
126
  const item1 = screen.getByRole("option", { name: "Item 1" });
161
127
 
@@ -164,14 +130,18 @@ describe("MultiSelectMenu", () => {
164
130
  });
165
131
 
166
132
  // Select Item 1
167
- await user.click(item1);
133
+ await act(async () => {
134
+ await user.click(item1);
135
+ });
168
136
 
169
137
  await waitFor(() => {
170
138
  expect(item1).toHaveAttribute("aria-selected", "true");
171
139
  });
172
140
 
173
141
  // Close Menu (MultiSelect does not close automatically)
174
- await user.click(toggleButton);
142
+ await act(async () => {
143
+ await user.click(toggleButton);
144
+ });
175
145
 
176
146
  // Make sure the menu is closed
177
147
  await waitFor(() =>
@@ -179,7 +149,9 @@ describe("MultiSelectMenu", () => {
179
149
  );
180
150
 
181
151
  // Open menu again
182
- await user.click(toggleButton);
152
+ await act(async () => {
153
+ await user.click(toggleButton);
154
+ });
183
155
 
184
156
  // Make sure the menu is open
185
157
  await waitFor(() =>
@@ -216,22 +188,30 @@ describe("MultiSelectMenu", () => {
216
188
  );
217
189
 
218
190
  // Open Menu
219
- await user.keyboard("{Tab}{Enter}");
191
+ await act(async () => {
192
+ await user.keyboard("{Tab}{Enter}");
193
+ });
220
194
  expect(await screen.findByRole("listbox")).toBeInTheDocument();
221
195
 
222
196
  // Check that the first item is highlighted
223
- await user.keyboard("{ArrowDown}");
197
+ await act(async () => {
198
+ await user.keyboard("{ArrowDown}");
199
+ });
224
200
  expect(screen.getByRole("listbox")).toHaveAttribute(
225
201
  "aria-activedescendant",
226
202
  screen.getByRole("option", { name: "Item 1" }).getAttribute("id")
227
203
  );
228
204
 
229
205
  // Select the first item
230
- await user.keyboard("{Enter}");
206
+ await act(async () => {
207
+ await user.keyboard("{Enter}");
208
+ });
231
209
 
232
210
  // select the second item
233
- await user.keyboard("{ArrowDown}");
234
- await user.keyboard(" ");
211
+ await act(async () => {
212
+ await user.keyboard("{ArrowDown}");
213
+ await user.keyboard(" ");
214
+ });
235
215
 
236
216
  // Check that the second item is highlighted and selected
237
217
  const item2 = screen.getByRole("option", { name: "Item 2" });
@@ -280,7 +260,9 @@ describe("MultiSelectMenu", () => {
280
260
  });
281
261
 
282
262
  // Open Menu
283
- await user.click(toggleButton);
263
+ await act(async () => {
264
+ await user.click(toggleButton);
265
+ });
284
266
 
285
267
  const item1 = screen.getByRole("option", { name: "Item 1" });
286
268
  const item2 = screen.getByRole("option", { name: "Item 2" });
@@ -333,7 +315,9 @@ describe("MultiSelectMenu", () => {
333
315
  });
334
316
 
335
317
  // Open Menu
336
- await user.click(toggleButton);
318
+ await act(async () => {
319
+ await user.click(toggleButton);
320
+ });
337
321
 
338
322
  const item1 = screen.getByRole("option", { name: "Item 1" });
339
323
 
@@ -342,12 +326,14 @@ describe("MultiSelectMenu", () => {
342
326
  });
343
327
 
344
328
  // Select Item 1
345
- await user.click(item1);
329
+ await act(async () => {
330
+ await user.click(item1);
331
+ });
346
332
 
347
333
  // Check that the menu is still open
348
334
  expect(await screen.findByRole("listbox")).toBeInTheDocument();
349
335
 
350
- // Check that callback called
351
- expect(mockSelectedItemsChange).toHaveBeenCalledTimes(1);
336
+ // Check that callback was called at least once
337
+ expect(mockSelectedItemsChange).toHaveBeenCalled();
352
338
  });
353
339
  });
@@ -3,6 +3,7 @@ import {
3
3
  MultiSelectMenu,
4
4
  MenuContent,
5
5
  MenuHeader,
6
+ MenuItem,
6
7
  MenuFooter,
7
8
  } from "../../index";
8
9
 
@@ -20,6 +21,17 @@ export const MultiSelectMenuTypeTest = () => (
20
21
  <MenuContent>Test</MenuContent>
21
22
  <MenuFooter>Test</MenuFooter>
22
23
  </MultiSelectMenu>
24
+ {/* Valid MultiSelectMenu allows menuItems to be passed using the menuItems prop */}
25
+ <MultiSelectMenu
26
+ menuToggleElement={<>test</>}
27
+ menuItems={[{ children: "Item Name", id: "item-1" }]}
28
+ />
29
+ {/* Valid MultiSelectMenu allows menuItems prop to be rendered using a MenuItemComponent prop */}
30
+ <MultiSelectMenu
31
+ menuToggleElement={<>test</>}
32
+ menuItems={[{ children: "Item Name", id: "item-1" }]}
33
+ MenuItemComponent={MenuItem}
34
+ />
23
35
  {/* Valid MultiSelectMenu accepts Downshift props and functions for useSelect */}
24
36
  <MultiSelectMenu
25
37
  menuToggleElement={<>test</>}
@@ -52,8 +64,23 @@ export const MultiSelectMenuTypeTest = () => (
52
64
  >
53
65
  Test
54
66
  </MultiSelectMenu>
55
- {/* @ts-expect-error - when no children provided */}
56
- <MultiSelectMenu menuToggleElement="test" />
67
+ {/* @ts-expect-error - when no children or items provided */}
68
+ <MultiSelectMenu menuToggleElement={<>test</>} />
69
+ {/* @ts-expect-error - when both children and MenuItemComponent provided */}
70
+ <MultiSelectMenu menuToggleElement={<>test</>} MenuItemComponent={MenuItem}>
71
+ Children
72
+ </MultiSelectMenu>
73
+ {/* @ts-expect-error - when both children and menuItems provided */}
74
+ <MultiSelectMenu menuToggleElement={<>test</>} menuItems={[]}>
75
+ Children
76
+ </MultiSelectMenu>
77
+ <MultiSelectMenu
78
+ menuToggleElement={<>test</>}
79
+ /* @ts-expect-error - when MenuItemComponent is of the wrong type */
80
+ MenuItemComponent={MenuContent}
81
+ >
82
+ Children
83
+ </MultiSelectMenu>
57
84
 
58
85
  <MultiSelectMenu
59
86
  menuToggleElement={<>test</>}
@@ -0,0 +1,109 @@
1
+ Feature: NestedMenu
2
+
3
+ A menu component that allows users to navigate through hierarchical menus
4
+
5
+ Rule: User behavior
6
+ Background:
7
+ Given an end user is using the component
8
+
9
+ Scenario: User can open the NestedMenu root menu
10
+ Given the NestedMenu is closed
11
+ When the menu button is clicked
12
+ Then it should open the parent NestedMenu
13
+
14
+ Scenario: User can open a child menu
15
+ Given the NestedMenu is open
16
+ When an item with a childMenuId is clicked
17
+ Then it should display the corresponding child menu
18
+
19
+ Scenario: User can view one child menu at a time
20
+ Given the NestedMenu is open
21
+ When a child menu is displayed
22
+ Then the parent menu is hidden
23
+
24
+ Scenario: User can return to a previous menu
25
+ Given the NestedMenu is open
26
+ And a child menu is displayed
27
+ Then the back button is visible
28
+ When the back button is clicked
29
+ Then it should return to the previous menu
30
+
31
+ Scenario: User can select a child menu without closing the menu
32
+ Given the NestedMenu is open
33
+ When an menu item that triggers a child menu is clicked
34
+ Then the menu will stay open
35
+ And the menu will not use its default open/close behavior
36
+
37
+ Rule: API
38
+ Background:
39
+ Given an engineer is implementing a NestedMenu
40
+
41
+ Scenario: NestedMenu accepts an initialMenuId prop
42
+ Given the NestedMenu is open
43
+ And an initialMenuId is passed
44
+ Then the corresponding menu should be displayed
45
+
46
+ Scenario: NestedMenu requires a menus prop
47
+ Given the NestedMenu is open
48
+ And a menus prop is not passed
49
+ Then the NestedMenu should not render
50
+
51
+ Scenario: NestedMenu only displays one menu at a time
52
+ Given the NestedMenu is open
53
+ And a child menu is being displayed
54
+ Then none of the parent or sibling menus should be displayed
55
+
56
+ Scenario: NestedMenu accepts an onBackButtonClick prop
57
+ Given the NestedMenu is open
58
+ And an onBackButtonClick function is passed
59
+ When the back button is clicked
60
+ Then the onBackButtonClick function should be called
61
+
62
+ Scenario Outline: NestedMenu can render MenuComponents
63
+ Given the NestedMenu is open
64
+ When the visible menu's MenuComponent is <MenuComponent>
65
+ Then a <MenuComponent> should be rendered with menuProps
66
+ And the menuProps should be of type <menuPropsType>
67
+
68
+ Examples:
69
+ | MenuComponent | menuPropsType |
70
+ |------------------|--------------------------|
71
+ | ActionMenu | TypeActionMenuProps |
72
+ | SingleSelectMenu | TypeSingleSelectMenuProps|
73
+ | MultiSelectMenu | TypeMultiSelectMenuProps |
74
+
75
+
76
+ Scenario: A Menu stays open when a menu item with a childMenuId is clicked
77
+ Given the NestedMenu is open
78
+ When an menu item that triggers a child menu is clicked
79
+ Then the menu will stay open
80
+ And the menu will not use its default open/close behavior
81
+
82
+ Scenario Outline: A Menu inherits its MenuComponent's open/close behavior for items without a childMenuId
83
+ Given the NestedMenu is open
84
+ And the visible menu's MenuComponent is <MenuComponent>
85
+ When an item without a childMenuId is clicked
86
+ And the <MenuComponent> will inherit its default behavior of <onClickBehavior>
87
+
88
+ Examples:
89
+ | MenuComponent | onClickBehavior |
90
+ |------------------|-------------- |
91
+ | ActionMenu | closing |
92
+ | SingleSelectMenu | not closing |
93
+ | MultiSelectMenu | not closing |
94
+
95
+ Scenario: NestedMenu renders a MenuHeaderComponent
96
+ Given the NestedMenu is open
97
+ And the visible menu's MenuHeaderComponent is not null
98
+ Then the MenuHeaderComponent should be rendered with menuHeaderProps
99
+
100
+ Scenario Outline: NestedMenu checks its types
101
+ When `<propName>` is passed a value
102
+ Then NestedMenu expects it to be of type `<propType>`
103
+
104
+ Examples: Prop Types
105
+ | propName | propType |
106
+ | --------------------- | -------------------------------------------------------------------------------- |
107
+ | menus | Record<string, {MenuComponent, menuProps, MenuHeaderComponent, menuHeaderProps}> |
108
+ | selectedMenuId | string |
109
+ | onBackButtonClick | function |
@@ -0,0 +1,157 @@
1
+ /* eslint-disable react-hooks/rules-of-hooks */
2
+ import React from "react";
3
+ import type { Meta, StoryObj } from "@storybook/react";
4
+
5
+ import { Box } from "@sproutsocial/seeds-react-box";
6
+ // eslint-disable-next-line no-restricted-imports
7
+ import { TEST_BOOKS } from "../__fixtures__/menu-test-data";
8
+
9
+ import {
10
+ ActionMenu,
11
+ SingleSelectMenu,
12
+ MenuToggleButton,
13
+ MultiSelectMenu,
14
+ NestedMenu,
15
+ } from "../index";
16
+ import { NestedMenuItem } from "./NestedMenuItem";
17
+ import { NestedMenuContent } from "./NestedMenuContent";
18
+
19
+ type CustomNestedMenuArgs = React.ComponentProps<typeof NestedMenu>;
20
+
21
+ const meta: Meta<CustomNestedMenuArgs> = {
22
+ title: "Under Development/NestedMenu",
23
+ component: NestedMenu,
24
+ parameters: {
25
+ controls: {
26
+ expanded: true,
27
+ },
28
+ },
29
+ decorators: [
30
+ (Story) => (
31
+ <Box display="flex" flexDirection="column" gap="16px" maxWidth={300}>
32
+ <Story />
33
+ </Box>
34
+ ),
35
+ ],
36
+ };
37
+
38
+ export default meta;
39
+
40
+ type Story = StoryObj<CustomNestedMenuArgs>;
41
+
42
+ const menus = {
43
+ root: {
44
+ MenuComponent: ActionMenu,
45
+ menuProps: {
46
+ menuItems: [
47
+ {
48
+ id: "parent-1",
49
+ children: "Open inner menu",
50
+ childMenuId: "inner",
51
+ },
52
+ {
53
+ id: "other-root",
54
+ children: "No child menu",
55
+ },
56
+ ],
57
+ },
58
+ MenuHeaderComponent: null,
59
+ },
60
+ inner: {
61
+ MenuComponent: ActionMenu,
62
+ menuProps: {
63
+ children: (
64
+ <NestedMenuContent>
65
+ <NestedMenuItem
66
+ id="item-2"
67
+ children="Books as ActionMenu"
68
+ childMenuId="books-action"
69
+ />
70
+ <NestedMenuItem
71
+ id="item-3"
72
+ children="Books as MultiSelect"
73
+ childMenuId="books-multiselect"
74
+ />
75
+ <NestedMenuItem
76
+ id="item-4"
77
+ children="Books as SingleSelect"
78
+ childMenuId="books-select"
79
+ />
80
+ </NestedMenuContent>
81
+ ),
82
+ },
83
+ menuHeaderProps: {
84
+ title: "Inner Menu",
85
+ },
86
+ },
87
+ "books-action": {
88
+ MenuComponent: ActionMenu,
89
+ menuProps: {
90
+ menuItems: TEST_BOOKS.map((book) => ({
91
+ id: book.id,
92
+ children: book.title,
93
+ childMenuId: "edit-book",
94
+ })),
95
+ },
96
+ menuHeaderProps: {
97
+ title: "Manage Books",
98
+ },
99
+ },
100
+ "books-multiselect": {
101
+ MenuComponent: MultiSelectMenu,
102
+ menuProps: {
103
+ menuItems: TEST_BOOKS.map((book) => ({
104
+ id: book.id,
105
+ children: book.title,
106
+ })),
107
+ },
108
+ menuHeaderProps: {
109
+ title: "Select Books",
110
+ },
111
+ },
112
+ "books-select": {
113
+ MenuComponent: SingleSelectMenu,
114
+ menuProps: {
115
+ menuItems: TEST_BOOKS.map((book) => ({
116
+ id: book.id,
117
+ children: book.title,
118
+ })),
119
+ },
120
+ menuHeaderProps: {
121
+ title: "Select One Book",
122
+ },
123
+ },
124
+ "edit-book": {
125
+ MenuComponent: ActionMenu,
126
+ menuProps: {
127
+ menuItems: [
128
+ {
129
+ id: "archive",
130
+ children: "Archive",
131
+ },
132
+ {
133
+ id: "delete",
134
+ children: "Delete",
135
+ },
136
+ ],
137
+ },
138
+ menuHeaderProps: {
139
+ title: "Edit Book",
140
+ backArrowLabel: "Back to Books",
141
+ },
142
+ },
143
+ };
144
+
145
+ export const NestedMenuMultiLevelStory: Story = {
146
+ name: "Multi Level NestedMenu",
147
+ render: (args) => {
148
+ return (
149
+ <NestedMenu
150
+ initialMenuId="root"
151
+ menus={menus}
152
+ backArrowLabel="Go Back"
153
+ menuToggleElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
154
+ />
155
+ );
156
+ },
157
+ };
@@ -0,0 +1,102 @@
1
+ import React, { useState } from "react";
2
+ import type { TypeMenuItemProps } from "../MenuItem";
3
+ import type {
4
+ TypeNestedMenuItemProps,
5
+ TypeNestedMenuProps,
6
+ } from "./NestedMenuTypes";
7
+ import { reduceReducers } from "../utils/reduceReducers";
8
+ import { NestedMenuHeader } from "./NestedMenuHeader";
9
+ import { nestedMenuStateReducer } from "../reducers/nestedMenuStateReducer";
10
+ import { NestedMenuContent } from "./NestedMenuContent";
11
+ import { NestedMenuProvider } from "./NestedMenuContext";
12
+ import { NestedMenuItem } from "./NestedMenuItem";
13
+
14
+ export const NestedMenu = <
15
+ I extends TypeMenuItemProps = TypeNestedMenuItemProps
16
+ >({
17
+ initialMenuId,
18
+ menus,
19
+ onBackButtonClick,
20
+ backArrowLabel,
21
+ menuToggleElement,
22
+ }: TypeNestedMenuProps<I>) => {
23
+ const [selectedMenuPath, setSelectedMenuPath] = useState([initialMenuId]);
24
+ const selectedMenuId = selectedMenuPath[selectedMenuPath.length - 1];
25
+
26
+ const setSelectedMenuId = (id: string) => {
27
+ setSelectedMenuPath((currentPath) => [...currentPath, id]);
28
+ };
29
+ const goBack = () => {
30
+ setSelectedMenuPath((currentPath) => currentPath.slice(0, -1));
31
+ onBackButtonClick?.(selectedMenuId);
32
+ };
33
+
34
+ const {
35
+ MenuComponent,
36
+ menuProps,
37
+ MenuHeaderComponent = NestedMenuHeader,
38
+ menuHeaderProps = {},
39
+ } = menus[selectedMenuId] || {};
40
+ const {
41
+ children,
42
+ menuItems,
43
+ MenuItemComponent,
44
+ stateReducer: externalStateReducer,
45
+ onStateChange: externalOnStateChange,
46
+ ...props
47
+ } = menuProps || {};
48
+
49
+ const stateReducer = reduceReducers(
50
+ nestedMenuStateReducer,
51
+ externalStateReducer
52
+ );
53
+
54
+ // Reactively reset the selected menu path when the menu closes
55
+ const onStateChange = (changes) => {
56
+ externalOnStateChange?.(changes);
57
+ if (changes.isOpen === false) {
58
+ // Wait for the menu to close before resetting to avoid flicker
59
+ setTimeout(() => {
60
+ setSelectedMenuPath([initialMenuId]);
61
+ }, 100);
62
+ }
63
+ };
64
+
65
+ const nestedMenuProps = {
66
+ menuToggleElement,
67
+ stateReducer,
68
+ onStateChange,
69
+ initialIsOpen: selectedMenuPath.length > 1,
70
+ children: (
71
+ <>
72
+ {/*This header can be suppressed by passing `null` for MenuHeaderComponent*/}
73
+ {MenuHeaderComponent && (
74
+ <MenuHeaderComponent
75
+ backArrowLabel={backArrowLabel}
76
+ {...menuHeaderProps}
77
+ />
78
+ )}
79
+ {/*children takes precedence over menuItems if both are passed.*/}
80
+ {children && children}
81
+ {menuItems && !children && (
82
+ <NestedMenuContent
83
+ menuItems={menuItems}
84
+ MenuItemComponent={MenuItemComponent ?? NestedMenuItem}
85
+ />
86
+ )}
87
+ </>
88
+ ),
89
+ ...props,
90
+ };
91
+
92
+ return (
93
+ <NestedMenuProvider
94
+ selectedMenuPath={selectedMenuPath}
95
+ setSelectedMenuPath={setSelectedMenuPath}
96
+ setSelectedMenuId={setSelectedMenuId}
97
+ goBack={goBack}
98
+ >
99
+ {MenuComponent && <MenuComponent {...nestedMenuProps} />}
100
+ </NestedMenuProvider>
101
+ );
102
+ };
@@ -0,0 +1,71 @@
1
+ import { MenuContent, type TypeMenuContentProps } from "../MenuContent/index";
2
+ import { useMenuContentContext } from "../MenuContext";
3
+ import type { TypeChildrenOrItems, TypeInternalItemProps } from "../types";
4
+ import { useNestedMenuContext } from "./NestedMenuContext";
5
+ import type { TypeMenuItemProps } from "../MenuItem/index";
6
+ import type { TypeNestedMenuItemProps } from "./NestedMenuTypes";
7
+ import { useEffect, useRef } from "react";
8
+ import { mergeRefs } from "@sproutsocial/seeds-react-utilities/src/index";
9
+ import { NestedMenuItem } from "./NestedMenuItem";
10
+
11
+ export const NestedMenuContent = <
12
+ I extends TypeMenuItemProps = TypeNestedMenuItemProps
13
+ >({
14
+ innerRef: innerRefProp = null,
15
+ onKeyDown: onKeyDownProp,
16
+ children,
17
+ menuItems,
18
+ MenuItemComponent = NestedMenuItem,
19
+ ...props
20
+ }: TypeMenuContentProps<I>) => {
21
+ const { highlightedIndex, items } = useMenuContentContext();
22
+ const { setSelectedMenuId, selectedMenuPath, goBack } =
23
+ useNestedMenuContext();
24
+ const internalRef = useRef<HTMLUListElement>(null);
25
+ // Merge our ref and the consumer's ref so they will both be updated
26
+ const innerRef = mergeRefs<HTMLUListElement>(internalRef, innerRefProp);
27
+
28
+ const contentProps = children
29
+ ? { children }
30
+ : ({ menuItems, MenuItemComponent } as TypeChildrenOrItems<I>);
31
+
32
+ // Focus the content on mount so that keyboard nav is available immediately
33
+ useEffect(() => {
34
+ internalRef?.current?.focus();
35
+ }, []);
36
+
37
+ const onKeyDown = (e) => {
38
+ // Call the consumer's onKeyDown handler first
39
+ onKeyDownProp?.(e);
40
+
41
+ // Handle navigating into and out of child menus
42
+ switch (e.key) {
43
+ case "ArrowRight":
44
+ case " ":
45
+ case "Enter":
46
+ if (highlightedIndex !== undefined && highlightedIndex !== -1) {
47
+ const item = items[
48
+ highlightedIndex
49
+ ] as TypeInternalItemProps<TypeNestedMenuItemProps>;
50
+ if (item?.menuItemProps?.childMenuId !== undefined) {
51
+ setSelectedMenuId?.(item?.menuItemProps?.childMenuId);
52
+ }
53
+ }
54
+ break;
55
+ case "ArrowLeft":
56
+ if (selectedMenuPath.length > 1) {
57
+ goBack?.();
58
+ }
59
+ break;
60
+ }
61
+ };
62
+
63
+ return (
64
+ <MenuContent
65
+ onKeyDown={onKeyDown}
66
+ innerRef={innerRef}
67
+ {...contentProps}
68
+ {...props}
69
+ />
70
+ );
71
+ };