@sproutsocial/seeds-react-menu 0.5.0 → 1.0.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 (104) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +72 -0
  3. package/dist/esm/index.js +1529 -440
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/index.d.mts +275 -92
  6. package/dist/index.d.ts +275 -92
  7. package/dist/index.js +1558 -444
  8. package/dist/index.js.map +1 -1
  9. package/package.json +15 -12
  10. package/src/ActionMenu/ActionMenu.stories.tsx +422 -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 +188 -0
  21. package/src/Autocomplete/SingleAutocomplete.tsx +74 -0
  22. package/src/Autocomplete/__tests__/Autocomplete.test.tsx +207 -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 +28 -12
  26. package/src/MenuContent/MenuContentTypes.ts +6 -4
  27. package/src/MenuContent/__tests__/MenuContent.typetest.tsx +8 -8
  28. package/src/MenuContext.tsx +192 -37
  29. package/src/MenuFooter/MenuFooter.stories.tsx +202 -0
  30. package/src/MenuFooter/__tests__/MenuFooter.test.tsx +16 -11
  31. package/src/MenuFooter/styles.tsx +1 -1
  32. package/src/MenuGroup/MenuGroup.feature +16 -16
  33. package/src/MenuGroup/MenuGroup.stories.tsx +248 -0
  34. package/src/MenuGroup/MenuGroup.tsx +12 -7
  35. package/src/MenuGroup/MenuGroupTypes.ts +6 -2
  36. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +6 -6
  37. package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +7 -7
  38. package/src/MenuGroup/styles.tsx +4 -2
  39. package/src/MenuHeader/MenuHeader.stories.tsx +276 -0
  40. package/src/MenuHeader/MenuHeader.tsx +12 -7
  41. package/src/MenuHeader/__tests__/MenuHeader.test.tsx +23 -13
  42. package/src/MenuHeader/styles.tsx +22 -14
  43. package/src/MenuItem/MenuItem.stories.tsx +507 -0
  44. package/src/MenuItem/MenuItem.tsx +23 -9
  45. package/src/MenuItem/MenuItemTypes.ts +9 -4
  46. package/src/MenuItem/__tests__/MenuItem.test.tsx +6 -6
  47. package/src/MenuItem/styles.tsx +23 -5
  48. package/src/MenuItem/useOptionProps.tsx +11 -9
  49. package/src/MenuLabel.tsx +2 -2
  50. package/src/MenuRoot/MenuRoot.tsx +52 -24
  51. package/src/MenuRoot/__tests__/MenuRoot.test.tsx +59 -32
  52. package/src/MenuSearchInput/MenuSearchInput.tsx +85 -0
  53. package/src/MenuSearchInput/__tests__/MenuSearchInput.test.tsx +157 -0
  54. package/src/MenuSearchInput/__tests__/MenuSearchInput.typetest.tsx +31 -0
  55. package/src/MenuSearchInput/index.ts +1 -0
  56. package/src/MenuSearchTokenInput/MenuSearchTokenInput.tsx +108 -0
  57. package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.test.tsx +247 -0
  58. package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.typetest.tsx +33 -0
  59. package/src/MenuSearchTokenInput/index.ts +1 -0
  60. package/src/MenuToggleButton/MenuToggleButton.stories.tsx +72 -0
  61. package/src/{MenuToggleButton.tsx → MenuToggleButton/MenuToggleButton.tsx} +6 -5
  62. package/src/MenuToggleButton/index.ts +1 -0
  63. package/src/MenuTokenInput.tsx +104 -0
  64. package/src/MultiSelectMenu/MultiSelectMenu.stories.tsx +222 -0
  65. package/src/MultiSelectMenu/MultiSelectMenu.tsx +38 -68
  66. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +8 -6
  67. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +46 -17
  68. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +6 -6
  69. package/src/NestedMenu/NestedMenu.feature +109 -0
  70. package/src/NestedMenu/NestedMenu.stories.tsx +157 -0
  71. package/src/NestedMenu/NestedMenu.tsx +102 -0
  72. package/src/NestedMenu/NestedMenuContent.tsx +75 -0
  73. package/src/NestedMenu/NestedMenuContext.tsx +56 -0
  74. package/src/NestedMenu/NestedMenuHeader.tsx +25 -0
  75. package/src/NestedMenu/NestedMenuItem.tsx +41 -0
  76. package/src/NestedMenu/NestedMenuTypes.ts +47 -0
  77. package/src/NestedMenu/__tests__/NestedMenu.test.tsx +31 -0
  78. package/src/NestedMenu/__tests__/NestedMenu.typetest.tsx +134 -0
  79. package/src/NestedMenu/index.ts +5 -0
  80. package/src/SingleSelectMenu/SingleSelectMenu.stories.tsx +233 -0
  81. package/src/SingleSelectMenu/SingleSelectMenu.tsx +23 -26
  82. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +7 -5
  83. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +6 -6
  84. package/src/SubmenuItem/SubmenuItem.feature +82 -0
  85. package/src/SubmenuItem/SubmenuItem.stories.tsx +163 -0
  86. package/src/SubmenuItem/SubmenuItem.tsx +258 -0
  87. package/src/SubmenuItem/SubmenuItemTypes.ts +24 -0
  88. package/src/SubmenuItem/__tests__/SubmenuItem.test.tsx +3 -0
  89. package/src/SubmenuItem/__tests__/SubmenuItem.typetest.tsx +112 -0
  90. package/src/SubmenuItem/index.ts +2 -0
  91. package/src/hooks/useDownshiftDefaults.tsx +117 -0
  92. package/src/hooks/useItemFiltering.tsx +68 -0
  93. package/src/hooks/useMenuChildren.tsx +205 -131
  94. package/src/index.ts +6 -0
  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 -30
  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/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 = [
@@ -53,7 +54,9 @@ describe("MultiSelectMenu", () => {
53
54
 
54
55
  const toggleButton = screen.getByRole("button", { name: "testable menu" });
55
56
 
56
- await user.click(toggleButton);
57
+ await act(async () => {
58
+ await user.click(toggleButton);
59
+ });
57
60
 
58
61
  const item1 = screen.getByRole("option", { name: "Item 1" });
59
62
  const item2 = screen.getByRole("option", { name: "Item 2" });
@@ -64,7 +67,9 @@ describe("MultiSelectMenu", () => {
64
67
  });
65
68
 
66
69
  // Select first item
67
- await user.click(item1);
70
+ await act(async () => {
71
+ await user.click(item1);
72
+ });
68
73
 
69
74
  await waitFor(() => {
70
75
  // Check that the first item is selected
@@ -72,7 +77,9 @@ describe("MultiSelectMenu", () => {
72
77
  });
73
78
 
74
79
  // Select second item
75
- await user.click(item2);
80
+ await act(async () => {
81
+ await user.click(item2);
82
+ });
76
83
 
77
84
  await waitFor(() => {
78
85
  // Check that the second item is selected
@@ -112,7 +119,9 @@ describe("MultiSelectMenu", () => {
112
119
  const toggleButton = screen.getByRole("button", { name: "testable menu" });
113
120
 
114
121
  // Open Menu
115
- await user.click(toggleButton);
122
+ await act(async () => {
123
+ await user.click(toggleButton);
124
+ });
116
125
 
117
126
  const item1 = screen.getByRole("option", { name: "Item 1" });
118
127
 
@@ -121,14 +130,18 @@ describe("MultiSelectMenu", () => {
121
130
  });
122
131
 
123
132
  // Select Item 1
124
- await user.click(item1);
133
+ await act(async () => {
134
+ await user.click(item1);
135
+ });
125
136
 
126
137
  await waitFor(() => {
127
138
  expect(item1).toHaveAttribute("aria-selected", "true");
128
139
  });
129
140
 
130
141
  // Close Menu (MultiSelect does not close automatically)
131
- await user.click(toggleButton);
142
+ await act(async () => {
143
+ await user.click(toggleButton);
144
+ });
132
145
 
133
146
  // Make sure the menu is closed
134
147
  await waitFor(() =>
@@ -136,7 +149,9 @@ describe("MultiSelectMenu", () => {
136
149
  );
137
150
 
138
151
  // Open menu again
139
- await user.click(toggleButton);
152
+ await act(async () => {
153
+ await user.click(toggleButton);
154
+ });
140
155
 
141
156
  // Make sure the menu is open
142
157
  await waitFor(() =>
@@ -173,22 +188,30 @@ describe("MultiSelectMenu", () => {
173
188
  );
174
189
 
175
190
  // Open Menu
176
- await user.keyboard("{Tab}{Enter}");
191
+ await act(async () => {
192
+ await user.keyboard("{Tab}{Enter}");
193
+ });
177
194
  expect(await screen.findByRole("listbox")).toBeInTheDocument();
178
195
 
179
196
  // Check that the first item is highlighted
180
- await user.keyboard("{ArrowDown}");
197
+ await act(async () => {
198
+ await user.keyboard("{ArrowDown}");
199
+ });
181
200
  expect(screen.getByRole("listbox")).toHaveAttribute(
182
201
  "aria-activedescendant",
183
202
  screen.getByRole("option", { name: "Item 1" }).getAttribute("id")
184
203
  );
185
204
 
186
205
  // Select the first item
187
- await user.keyboard("{Enter}");
206
+ await act(async () => {
207
+ await user.keyboard("{Enter}");
208
+ });
188
209
 
189
210
  // select the second item
190
- await user.keyboard("{ArrowDown}");
191
- await user.keyboard(" ");
211
+ await act(async () => {
212
+ await user.keyboard("{ArrowDown}");
213
+ await user.keyboard(" ");
214
+ });
192
215
 
193
216
  // Check that the second item is highlighted and selected
194
217
  const item2 = screen.getByRole("option", { name: "Item 2" });
@@ -237,7 +260,9 @@ describe("MultiSelectMenu", () => {
237
260
  });
238
261
 
239
262
  // Open Menu
240
- await user.click(toggleButton);
263
+ await act(async () => {
264
+ await user.click(toggleButton);
265
+ });
241
266
 
242
267
  const item1 = screen.getByRole("option", { name: "Item 1" });
243
268
  const item2 = screen.getByRole("option", { name: "Item 2" });
@@ -290,7 +315,9 @@ describe("MultiSelectMenu", () => {
290
315
  });
291
316
 
292
317
  // Open Menu
293
- await user.click(toggleButton);
318
+ await act(async () => {
319
+ await user.click(toggleButton);
320
+ });
294
321
 
295
322
  const item1 = screen.getByRole("option", { name: "Item 1" });
296
323
 
@@ -299,12 +326,14 @@ describe("MultiSelectMenu", () => {
299
326
  });
300
327
 
301
328
  // Select Item 1
302
- await user.click(item1);
329
+ await act(async () => {
330
+ await user.click(item1);
331
+ });
303
332
 
304
333
  // Check that the menu is still open
305
334
  expect(await screen.findByRole("listbox")).toBeInTheDocument();
306
335
 
307
- // Check that callback called
308
- expect(mockSelectedItemsChange).toHaveBeenCalledTimes(1);
336
+ // Check that callback was called at least once
337
+ expect(mockSelectedItemsChange).toHaveBeenCalled();
309
338
  });
310
339
  });
@@ -21,15 +21,15 @@ export const MultiSelectMenuTypeTest = () => (
21
21
  <MenuContent>Test</MenuContent>
22
22
  <MenuFooter>Test</MenuFooter>
23
23
  </MultiSelectMenu>
24
- {/* Valid MultiSelectMenu allows items to be passed using the items prop */}
24
+ {/* Valid MultiSelectMenu allows menuItems to be passed using the menuItems prop */}
25
25
  <MultiSelectMenu
26
26
  menuToggleElement={<>test</>}
27
- items={[{ children: "Item Name", id: "item-1" }]}
27
+ menuItems={[{ children: "Item Name", id: "item-1" }]}
28
28
  />
29
- {/* Valid MultiSelectMenu allows items prop to be rendered using a MenuItemComponent prop */}
29
+ {/* Valid MultiSelectMenu allows menuItems prop to be rendered using a MenuItemComponent prop */}
30
30
  <MultiSelectMenu
31
31
  menuToggleElement={<>test</>}
32
- items={[{ children: "Item Name", id: "item-1" }]}
32
+ menuItems={[{ children: "Item Name", id: "item-1" }]}
33
33
  MenuItemComponent={MenuItem}
34
34
  />
35
35
  {/* Valid MultiSelectMenu accepts Downshift props and functions for useSelect */}
@@ -70,8 +70,8 @@ export const MultiSelectMenuTypeTest = () => (
70
70
  <MultiSelectMenu menuToggleElement={<>test</>} MenuItemComponent={MenuItem}>
71
71
  Children
72
72
  </MultiSelectMenu>
73
- {/* @ts-expect-error - when both children and items provided */}
74
- <MultiSelectMenu menuToggleElement={<>test</>} items={[]}>
73
+ {/* @ts-expect-error - when both children and menuItems provided */}
74
+ <MultiSelectMenu menuToggleElement={<>test</>} menuItems={[]}>
75
75
  Children
76
76
  </MultiSelectMenu>
77
77
  <MultiSelectMenu
@@ -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,75 @@
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
+ 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
+ };
72
+
73
+ NestedMenuContent.__MENU_PRIMITIVE_TYPE = "MenuContent";
74
+
75
+ export { NestedMenuContent };
@@ -0,0 +1,56 @@
1
+ import React, { createContext, useContext } from "react";
2
+ import type { TypeNotEmptyChildren } from "../types";
3
+
4
+ export interface TypeNestedMenuContext {
5
+ selectedMenuPath: string[];
6
+ setSelectedMenuId?: (id: string) => void;
7
+ setSelectedMenuPath?: (path: string[]) => void;
8
+ goBack?: () => void;
9
+ }
10
+
11
+ const defaultNestedMenuContext: TypeNestedMenuContext = {
12
+ selectedMenuPath: [],
13
+ setSelectedMenuId: () => {},
14
+ setSelectedMenuPath: () => {},
15
+ goBack: () => {},
16
+ };
17
+
18
+ export interface TypeNestedMenuProviderProps extends TypeNestedMenuContext {
19
+ children: TypeNotEmptyChildren;
20
+ }
21
+
22
+ /**
23
+ * NestedMenuContext factory, sets the type and the initial object
24
+ */
25
+ export const NestedMenuContext = createContext<TypeNestedMenuContext>(
26
+ defaultNestedMenuContext
27
+ );
28
+
29
+ /**
30
+ * Provider Component sets the context values in the provider
31
+ */
32
+ export const NestedMenuProvider = ({
33
+ children,
34
+ ...nestedMenuProps
35
+ }: TypeNestedMenuProviderProps) => {
36
+ return (
37
+ <NestedMenuContext.Provider value={nestedMenuProps}>
38
+ {children}
39
+ </NestedMenuContext.Provider>
40
+ );
41
+ };
42
+
43
+ /**
44
+ * Utility hook for consuming NestedMenuContext
45
+ */
46
+ export const useNestedMenuContext = (): TypeNestedMenuContext => {
47
+ const nestedMenuContextValue = useContext(NestedMenuContext);
48
+
49
+ if (nestedMenuContextValue === null) {
50
+ throw new Error(
51
+ "useNestedMenuContext must be used within a <NestedMenuContext.Provider />"
52
+ );
53
+ }
54
+
55
+ return nestedMenuContextValue;
56
+ };