@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +32 -0
- package/dist/esm/index.js +1553 -423
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +303 -122
- package/dist/index.d.ts +303 -122
- package/dist/index.js +1561 -405
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/src/ActionMenu/ActionMenu.stories.tsx +426 -0
- package/src/ActionMenu/ActionMenu.tsx +30 -26
- package/src/ActionMenu/ActionMenuTypes.ts +13 -8
- package/src/ActionMenu/__tests__/ActionMenu.test.tsx +17 -30
- package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +32 -3
- package/src/Autocomplete/Autocomplete.stories.tsx +61 -2
- package/src/Autocomplete/Autocomplete.tsx +15 -74
- package/src/Autocomplete/AutocompleteInput.tsx +18 -27
- package/src/Autocomplete/AutocompleteTokenInput.tsx +50 -0
- package/src/Autocomplete/AutocompleteTypes.ts +27 -11
- package/src/Autocomplete/MultiAutocomplete.tsx +189 -0
- package/src/Autocomplete/SingleAutocomplete.tsx +74 -0
- package/src/Autocomplete/__tests__/Autocomplete.test.tsx +171 -3
- package/src/Autocomplete/__tests__/Autocomplete.typetest.tsx +116 -3
- package/src/MenuContent/MenuContent.stories.tsx +164 -0
- package/src/MenuContent/MenuContent.tsx +35 -8
- package/src/MenuContent/MenuContentTypes.ts +7 -5
- package/src/MenuContent/__tests__/MenuContent.typetest.tsx +18 -2
- package/src/MenuContext.tsx +191 -37
- package/src/MenuFooter/MenuFooter.stories.tsx +202 -0
- package/src/MenuFooter/__tests__/MenuFooter.test.tsx +16 -11
- package/src/MenuGroup/MenuGroup.feature +21 -21
- package/src/MenuGroup/MenuGroup.stories.tsx +248 -0
- package/src/MenuGroup/MenuGroup.tsx +9 -7
- package/src/MenuGroup/MenuGroupTypes.ts +8 -6
- package/src/MenuGroup/__tests__/MenuGroup.test.tsx +6 -6
- package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +23 -4
- package/src/MenuGroup/styles.tsx +4 -2
- package/src/MenuHeader/MenuHeader.stories.tsx +276 -0
- package/src/MenuHeader/MenuHeader.tsx +3 -1
- package/src/MenuHeader/__tests__/MenuHeader.test.tsx +23 -13
- package/src/MenuHeader/styles.tsx +18 -14
- package/src/MenuItem/MenuItem.stories.tsx +507 -0
- package/src/MenuItem/MenuItem.tsx +18 -9
- package/src/MenuItem/MenuItemTypes.ts +9 -4
- package/src/MenuItem/__tests__/MenuItem.test.tsx +6 -6
- package/src/MenuItem/styles.tsx +21 -5
- package/src/MenuItem/useOptionProps.tsx +10 -8
- package/src/MenuLabel.tsx +2 -2
- package/src/MenuRoot/MenuRoot.tsx +37 -23
- package/src/MenuRoot/__tests__/MenuRoot.test.tsx +59 -32
- package/src/MenuSearchInput/MenuSearchInput.tsx +63 -0
- package/src/MenuSearchInput/__tests__/MenuSearchInput.test.tsx +157 -0
- package/src/MenuSearchInput/__tests__/MenuSearchInput.typetest.tsx +31 -0
- package/src/MenuSearchInput/index.ts +1 -0
- package/src/MenuSearchTokenInput/MenuSearchTokenInput.tsx +98 -0
- package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.test.tsx +247 -0
- package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.typetest.tsx +33 -0
- package/src/MenuSearchTokenInput/index.ts +1 -0
- package/src/MenuToggleButton/MenuToggleButton.stories.tsx +72 -0
- package/src/{MenuToggleButton.tsx → MenuToggleButton/MenuToggleButton.tsx} +6 -5
- package/src/MenuToggleButton/index.ts +1 -0
- package/src/MenuTokenInput.tsx +104 -0
- package/src/MultiSelectMenu/MultiSelectMenu.stories.tsx +214 -0
- package/src/MultiSelectMenu/MultiSelectMenu.tsx +44 -67
- package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +9 -5
- package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +46 -60
- package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +29 -2
- package/src/NestedMenu/NestedMenu.feature +109 -0
- package/src/NestedMenu/NestedMenu.stories.tsx +157 -0
- package/src/NestedMenu/NestedMenu.tsx +102 -0
- package/src/NestedMenu/NestedMenuContent.tsx +71 -0
- package/src/NestedMenu/NestedMenuContext.tsx +56 -0
- package/src/NestedMenu/NestedMenuHeader.tsx +25 -0
- package/src/NestedMenu/NestedMenuItem.tsx +37 -0
- package/src/NestedMenu/NestedMenuTypes.ts +47 -0
- package/src/NestedMenu/__tests__/NestedMenu.test.tsx +31 -0
- package/src/NestedMenu/__tests__/NestedMenu.typetest.tsx +134 -0
- package/src/NestedMenu/index.ts +5 -0
- package/src/SingleSelectMenu/SingleSelectMenu.stories.tsx +227 -0
- package/src/SingleSelectMenu/SingleSelectMenu.tsx +29 -25
- package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +9 -5
- package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +32 -2
- package/src/SubmenuItem/SubmenuItem.feature +82 -0
- package/src/SubmenuItem/SubmenuItem.stories.tsx +163 -0
- package/src/SubmenuItem/SubmenuItem.tsx +254 -0
- package/src/SubmenuItem/SubmenuItemTypes.ts +24 -0
- package/src/SubmenuItem/__tests__/SubmenuItem.test.tsx +3 -0
- package/src/SubmenuItem/__tests__/SubmenuItem.typetest.tsx +112 -0
- package/src/SubmenuItem/index.ts +2 -0
- package/src/__tests__/Menu.test.tsx +38 -0
- package/src/hooks/useDownshiftDefaults.tsx +107 -0
- package/src/hooks/useItemFiltering.tsx +66 -0
- package/src/hooks/useMenuChildren.tsx +279 -0
- package/src/index.ts +7 -1
- package/src/menuTestingUtils.tsx +21 -10
- package/src/reducers/nestedMenuStateReducer.tsx +23 -0
- package/src/reducers/useComboboxStateReducer.tsx +26 -8
- package/src/reducers/useSelectStateReducer.tsx +24 -6
- package/src/storybookUtilities/menu-storybook-components.tsx +5 -3
- package/src/types.ts +38 -5
- package/src/utils/downshiftDefaults.ts +9 -0
- package/src/utils/getMultiSelectOverrides.ts +21 -0
- package/src/utils/reduceReducers.ts +19 -0
- package/src/hooks/useItemsFromChildren.tsx +0 -131
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
206
|
+
await act(async () => {
|
|
207
|
+
await user.keyboard("{Enter}");
|
|
208
|
+
});
|
|
231
209
|
|
|
232
210
|
// select the second item
|
|
233
|
-
await
|
|
234
|
-
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
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=
|
|
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
|
+
};
|