@sproutsocial/seeds-react-menu 0.1.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 (91) hide show
  1. package/.eslintrc.js +4 -0
  2. package/.turbo/turbo-build.log +21 -0
  3. package/CHANGELOG.md +46 -0
  4. package/dist/esm/index.js +2651 -0
  5. package/dist/esm/index.js.map +1 -0
  6. package/dist/index.d.mts +197 -0
  7. package/dist/index.d.ts +197 -0
  8. package/dist/index.js +2670 -0
  9. package/dist/index.js.map +1 -0
  10. package/jest.config.js +12 -0
  11. package/package.json +50 -0
  12. package/src/ActionMenu/ActionMenu.feature +20 -0
  13. package/src/ActionMenu/ActionMenu.tsx +113 -0
  14. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +99 -0
  15. package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +73 -0
  16. package/src/ActionMenu/index.ts +1 -0
  17. package/src/ComboBox/ComboBox.feature +96 -0
  18. package/src/ComboBox/TokenInput.feature +84 -0
  19. package/src/MenuContent/MenuContent.tsx +28 -0
  20. package/src/MenuContent/MenuContentTypes.ts +7 -0
  21. package/src/MenuContent/__tests__/MenuContent.test.tsx +59 -0
  22. package/src/MenuContent/__tests__/MenuContent.typetest.tsx +18 -0
  23. package/src/MenuContent/index.tsx +2 -0
  24. package/src/MenuContent/styles.tsx +10 -0
  25. package/src/MenuContext.tsx +116 -0
  26. package/src/MenuFooter/MenuFooter.feature +30 -0
  27. package/src/MenuFooter/MenuFooter.tsx +7 -0
  28. package/src/MenuFooter/MenuFooterTypes.ts +1 -0
  29. package/src/MenuFooter/__tests__/MenuFooter.test.tsx +76 -0
  30. package/src/MenuFooter/__tests__/MenuFooter.typetest.tsx +20 -0
  31. package/src/MenuFooter/index.ts +2 -0
  32. package/src/MenuFooter/styles.tsx +7 -0
  33. package/src/MenuGroup/MenuGroup.feature +114 -0
  34. package/src/MenuGroup/MenuGroup.tsx +33 -0
  35. package/src/MenuGroup/MenuGroupTypes.ts +29 -0
  36. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +95 -0
  37. package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +45 -0
  38. package/src/MenuGroup/index.tsx +8 -0
  39. package/src/MenuGroup/styles.tsx +37 -0
  40. package/src/MenuHeader/MenuHeader.feature +59 -0
  41. package/src/MenuHeader/MenuHeader.tsx +30 -0
  42. package/src/MenuHeader/MenuHeaderTypes.ts +26 -0
  43. package/src/MenuHeader/__tests__/MenuHeader.test.tsx +67 -0
  44. package/src/MenuHeader/__tests__/MenuHeader.typetest.tsx +27 -0
  45. package/src/MenuHeader/index.ts +2 -0
  46. package/src/MenuHeader/styles.tsx +75 -0
  47. package/src/MenuItem/MenuItem.feature +88 -0
  48. package/src/MenuItem/MenuItem.tsx +44 -0
  49. package/src/MenuItem/MenuItemTypes.ts +37 -0
  50. package/src/MenuItem/__tests__/MenuItem.test.tsx +228 -0
  51. package/src/MenuItem/__tests__/MenuItem.typetest.tsx +41 -0
  52. package/src/MenuItem/index.ts +5 -0
  53. package/src/MenuItem/styles.tsx +71 -0
  54. package/src/MenuItemSelectable/MenuItemSelectable.feature +99 -0
  55. package/src/MenuItemSelectable/MenuItemSelectable.tsx +136 -0
  56. package/src/MenuItemSelectable/MenuItemSelectableTypes.ts +15 -0
  57. package/src/MenuItemSelectable/__tests__/MenuItemSelectable.test.tsx +303 -0
  58. package/src/MenuItemSelectable/__tests__/MenuItemSelectable.typetest.tsx +56 -0
  59. package/src/MenuItemSelectable/index.ts +5 -0
  60. package/src/MenuItemSelectable/styles.tsx +21 -0
  61. package/src/MenuLabel.tsx +12 -0
  62. package/src/MenuRoot/MenuRoot.feature +93 -0
  63. package/src/MenuRoot/MenuRoot.tsx +70 -0
  64. package/src/MenuRoot/__tests__/MenuRoot.test.tsx +197 -0
  65. package/src/MenuRoot/__tests__/MenuRoot.typetest.tsx +76 -0
  66. package/src/MenuRoot/index.ts +1 -0
  67. package/src/MenuToggleButton.tsx +29 -0
  68. package/src/MultiSelectMenu/MultiSelectMenu.feature +70 -0
  69. package/src/MultiSelectMenu/MultiSelectMenu.tsx +133 -0
  70. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +8 -0
  71. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +349 -0
  72. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +78 -0
  73. package/src/MultiSelectMenu/index.ts +1 -0
  74. package/src/SingleSelectMenu/SingleSelectMenu.feature +23 -0
  75. package/src/SingleSelectMenu/SingleSelectMenu.tsx +56 -0
  76. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +9 -0
  77. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.test.tsx +167 -0
  78. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +95 -0
  79. package/src/SingleSelectMenu/index.ts +3 -0
  80. package/src/__tests__/Menu.test.tsx +423 -0
  81. package/src/constants.ts +17 -0
  82. package/src/hooks/useItemsFromChildren.tsx +81 -0
  83. package/src/index.ts +20 -0
  84. package/src/menuTestingUtils.tsx +72 -0
  85. package/src/reducers/__tests__/useSelectStateReducer.test.tsx +37 -0
  86. package/src/reducers/useSelectStateReducer.tsx +27 -0
  87. package/src/styled.d.ts +7 -0
  88. package/src/types.ts +16 -0
  89. package/src/utils/itemToString.ts +5 -0
  90. package/tsconfig.json +12 -0
  91. package/tsup.config.ts +11 -0
@@ -0,0 +1,133 @@
1
+ import React from "react";
2
+ import {
3
+ useSelect,
4
+ useMultipleSelection,
5
+ type UseSelectState,
6
+ } from "downshift";
7
+ import { useItemsFromChildren } from "../hooks/useItemsFromChildren";
8
+ import {
9
+ defaultUseSelectProps,
10
+ useSelectStateReducer,
11
+ } from "../reducers/useSelectStateReducer";
12
+ import { itemToString } from "../utils/itemToString";
13
+ import { MenuRoot } from "../MenuRoot";
14
+ import type { TypeMultiSelectMenuProps } from "./MultiSelectMenuTypes";
15
+ import type { TypeDefaultItemProps } from "../types";
16
+
17
+ const selectReducerForMultiSelect: TypeMultiSelectMenuProps["stateReducer"] = (
18
+ state,
19
+ actionAndChanges
20
+ ) => {
21
+ const { changes, type } = actionAndChanges;
22
+ switch (type) {
23
+ case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
24
+ case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
25
+ case useSelect.stateChangeTypes.ItemClick:
26
+ return {
27
+ ...changes,
28
+ isOpen: true, // keep the menu open after selection.
29
+ // TODO: Confirm that keeping highlightedIndex the same is the desired UX
30
+ // highlightedIndex: 0, // with the first option highlighted.
31
+ highlightedIndex: state.highlightedIndex, // keep highlightedIndex the same instead of returning to 0
32
+ };
33
+ }
34
+ return changes;
35
+ };
36
+
37
+ export const MultiSelectMenu = ({
38
+ children,
39
+ stateReducer: externalStateReducer,
40
+ getA11yMultiStatusMessage,
41
+ onMultiSelectStateChange,
42
+ multiSelectStateReducer,
43
+ ...useSelectProps
44
+ }: TypeMultiSelectMenuProps) => {
45
+ const { allMenuItems, menuItemsMap } = useItemsFromChildren({ children });
46
+
47
+ const multiSelectOverrides = Object.entries({
48
+ ...useSelectProps,
49
+ getA11yStatusMessage: getA11yMultiStatusMessage,
50
+ onStateChange: onMultiSelectStateChange,
51
+ stateReducer: multiSelectStateReducer,
52
+ }).reduce(
53
+ (a, [k, v]) => (v ? ((a[k] = v), a) : a),
54
+ {} as {
55
+ getA11yStatusMessage?: TypeMultiSelectMenuProps["getA11yMultiStatusMessage"];
56
+ onStateChange?: TypeMultiSelectMenuProps["onMultiSelectStateChange"];
57
+ stateReducer?: TypeMultiSelectMenuProps["multiSelectStateReducer"];
58
+ }
59
+ );
60
+ const {
61
+ getSelectedItemProps,
62
+ addSelectedItem,
63
+ removeSelectedItem,
64
+ selectedItems,
65
+ getDropdownProps,
66
+ ...useMultipleSelectionReturnProps
67
+ } = useMultipleSelection<TypeDefaultItemProps>(multiSelectOverrides);
68
+
69
+ const { isOpen, ...useSelectReturnProps } = useSelect({
70
+ ...defaultUseSelectProps,
71
+ items: allMenuItems,
72
+ itemToString,
73
+ selectedItem: null,
74
+ defaultHighlightedIndex: 0,
75
+ stateReducer: (state, actionAndChanges) => {
76
+ let newState: Partial<UseSelectState<TypeDefaultItemProps>>;
77
+ newState = useSelectStateReducer(state, actionAndChanges);
78
+ newState = selectReducerForMultiSelect(state, {
79
+ ...actionAndChanges,
80
+ changes: newState,
81
+ });
82
+ return externalStateReducer
83
+ ? externalStateReducer(state, {
84
+ ...actionAndChanges,
85
+ changes: newState,
86
+ })
87
+ : newState;
88
+ },
89
+ onStateChange: ({ type, selectedItem: newSelectedItem }) => {
90
+ switch (type) {
91
+ case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
92
+ case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
93
+ case useSelect.stateChangeTypes.ItemClick:
94
+ case useSelect.stateChangeTypes.ToggleButtonBlur:
95
+ if (!newSelectedItem) break;
96
+ if (selectedItems?.find((item) => item?.id === newSelectedItem.id)) {
97
+ removeSelectedItem(newSelectedItem);
98
+ } else {
99
+ addSelectedItem(newSelectedItem);
100
+ }
101
+ break;
102
+ default:
103
+ break;
104
+ }
105
+ },
106
+ ...useSelectProps,
107
+ });
108
+
109
+ return (
110
+ <MenuRoot
111
+ {...{
112
+ isListbox: true,
113
+ items: allMenuItems,
114
+ itemsMap: menuItemsMap,
115
+ itemToString,
116
+ isOpen,
117
+ getSelectedItemProps,
118
+ addSelectedItem,
119
+ removeSelectedItem,
120
+ selectedItems,
121
+ getDropdownProps,
122
+ getA11yMultiStatusMessage,
123
+ onMultiSelectStateChange,
124
+ multiSelectStateReducer,
125
+ ...useSelectProps,
126
+ ...useSelectReturnProps,
127
+ ...useMultipleSelectionReturnProps,
128
+ }}
129
+ >
130
+ {children}
131
+ </MenuRoot>
132
+ );
133
+ };
@@ -0,0 +1,8 @@
1
+ import type { TypeMenuRootProps, TypeMenuRootOwnProps } from "../MenuRoot";
2
+ import type { TypeDownshiftMultiSelectProps } from "../MenuContext";
3
+
4
+ export interface TypeMultiSelectMenuProps
5
+ extends TypeMenuRootOwnProps,
6
+ TypeDownshiftMultiSelectProps {
7
+ children: TypeMenuRootProps["children"];
8
+ }
@@ -0,0 +1,349 @@
1
+ /* eslint-disable testing-library/no-unnecessary-act */
2
+ import {
3
+ MultiSelectMenu,
4
+ MenuContent,
5
+ MenuToggleButton,
6
+ MenuItemSelectable,
7
+ MenuGroup,
8
+ } from "../../index";
9
+ import {
10
+ render,
11
+ screen,
12
+ fireEvent,
13
+ act,
14
+ cleanup,
15
+ waitFor,
16
+ } from "@sproutsocial/seeds-react-testing-library";
17
+
18
+ const menuItems = [
19
+ { id: "test-item-1", index: 0, label: "Item 1" },
20
+ { id: "test-item-2", index: 1, label: "Item 2" },
21
+ { id: "test-item-3", index: 2, label: "Item 3" },
22
+ { id: "test-item-4", index: 3, label: "Item 4" },
23
+ ];
24
+
25
+ describe("MultiSelectMenu", () => {
26
+ afterEach(() => {
27
+ cleanup();
28
+ });
29
+
30
+ it("should render the menuItemSelectables when provided as children", async () => {
31
+ const { user } = render(
32
+ <MultiSelectMenu
33
+ triggerElement={
34
+ <MenuToggleButton aria-label="testable menu" role="button">
35
+ Testable Menu
36
+ </MenuToggleButton>
37
+ }
38
+ >
39
+ <MenuContent>
40
+ <MenuGroup>
41
+ {menuItems.map((item) => (
42
+ <MenuItemSelectable key={item.id} id={item.id} label={item.label}>
43
+ {item.label}
44
+ </MenuItemSelectable>
45
+ ))}
46
+ </MenuGroup>
47
+ </MenuContent>
48
+ </MultiSelectMenu>
49
+ );
50
+
51
+ // Check that the menu is closed
52
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
53
+
54
+ const toggleButton = screen.getByRole("button", { name: "testable menu" });
55
+
56
+ // Open Menu
57
+ await user.click(toggleButton);
58
+
59
+ await waitFor(() => {
60
+ // Check that the menu is open
61
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
62
+ // Check that the menu items are present
63
+ expect(
64
+ screen.getByRole("option", { name: "Item 1" })
65
+ ).toBeInTheDocument();
66
+ expect(
67
+ // Check that the menu items are present
68
+ screen.getByRole("option", { name: "Item 2" })
69
+ ).toBeInTheDocument();
70
+ });
71
+ });
72
+
73
+ xit("should allow for multiple items to be selected", async () => {
74
+ const { user } = render(
75
+ <MultiSelectMenu
76
+ triggerElement={
77
+ <MenuToggleButton aria-label="testable menu" role="button">
78
+ Testable Menu
79
+ </MenuToggleButton>
80
+ }
81
+ >
82
+ <MenuContent>
83
+ <MenuGroup>
84
+ {menuItems.map((item) => (
85
+ <MenuItemSelectable
86
+ key={item.id}
87
+ id={item.id}
88
+ data-testid={item.id}
89
+ label={item.label}
90
+ >
91
+ {item.label}
92
+ </MenuItemSelectable>
93
+ ))}
94
+ </MenuGroup>
95
+ </MenuContent>
96
+ </MultiSelectMenu>
97
+ );
98
+
99
+ const toggleButton = screen.getByRole("button", { name: "testable menu" });
100
+
101
+ await user.click(toggleButton);
102
+
103
+ const item1 = screen.getByRole("option", { name: "Item 1" });
104
+ const item2 = screen.getByRole("option", { name: "Item 2" });
105
+
106
+ // For some reason the pointer-events are not being set to none on render. This essentially waits for the pointer-events to be set to none before getting to the actual tests.
107
+ await waitFor(() => {
108
+ expect(item1).not.toHaveStyle("pointer-events: none");
109
+ });
110
+
111
+ // Select first item
112
+ await user.click(item1);
113
+
114
+ await waitFor(() => {
115
+ // Check that the first item is selected
116
+ expect(item1).toHaveAttribute("aria-checked", "true");
117
+ });
118
+
119
+ // Select second item
120
+ // TODO: can this be done in the same act as the first item?
121
+ await user.click(item2);
122
+
123
+ await waitFor(() => {
124
+ // Check that the second item is selected
125
+ expect(item2).toHaveAttribute("aria-checked", "true");
126
+ });
127
+ });
128
+
129
+ xit("Should maintain selected state when closed and reopened", async () => {
130
+ const { user } = render(
131
+ <MultiSelectMenu
132
+ triggerElement={
133
+ <MenuToggleButton aria-label="testable menu" role="button">
134
+ Testable Menu
135
+ </MenuToggleButton>
136
+ }
137
+ >
138
+ <MenuContent>
139
+ <MenuGroup>
140
+ {menuItems.map((item) => (
141
+ <MenuItemSelectable
142
+ key={item.id}
143
+ id={item.id}
144
+ data-testid={item.id}
145
+ label={item.label}
146
+ >
147
+ {item.label}
148
+ </MenuItemSelectable>
149
+ ))}
150
+ </MenuGroup>
151
+ </MenuContent>
152
+ </MultiSelectMenu>
153
+ );
154
+
155
+ const toggleButton = screen.getByRole("button", { name: "testable menu" });
156
+
157
+ // Open Menu
158
+ await user.click(toggleButton);
159
+
160
+ const item1 = screen.getByRole("option", { name: "Item 1" });
161
+
162
+ await waitFor(() => {
163
+ expect(item1).not.toHaveStyle("pointer-events: none");
164
+ });
165
+
166
+ // Select Item 1
167
+ await user.click(item1);
168
+
169
+ await waitFor(() => {
170
+ expect(item1).toHaveAttribute("aria-checked", "true");
171
+ });
172
+
173
+ // Close Menu (MultiSelect does not close automatically)
174
+ await user.click(toggleButton);
175
+
176
+ // Make sure the menu is closed
177
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
178
+
179
+ // Open menu again
180
+ await user.click(toggleButton);
181
+
182
+ await waitFor(() => {
183
+ // Make sure the menu is open
184
+ expect(screen.getByRole("listbox")).toBeInTheDocument();
185
+ // Make sure the item is still selected
186
+ expect(item1).toHaveAttribute("aria-checked", "true");
187
+ });
188
+ });
189
+
190
+ xit("should be able to navigate using the keyboard", async () => {
191
+ const { user } = render(
192
+ <MultiSelectMenu
193
+ triggerElement={
194
+ <MenuToggleButton aria-label="testable menu" role="button">
195
+ Testable Menu
196
+ </MenuToggleButton>
197
+ }
198
+ >
199
+ <MenuContent>
200
+ <MenuGroup>
201
+ {menuItems.map((item) => (
202
+ <MenuItemSelectable
203
+ key={item.id}
204
+ id={item.id}
205
+ data-testid={item.id}
206
+ label={item.label}
207
+ >
208
+ {item.label}
209
+ </MenuItemSelectable>
210
+ ))}
211
+ </MenuGroup>
212
+ </MenuContent>
213
+ </MultiSelectMenu>
214
+ );
215
+
216
+ const toggleButton = screen.getByRole("button", { name: "testable menu" });
217
+
218
+ // Open Menu;
219
+ fireEvent.focus(toggleButton);
220
+ await user.keyboard("{Enter}");
221
+ await user.keyboard("{ArrowDown}");
222
+
223
+ await waitFor(() => {
224
+ // Check that the first item is highlighted
225
+ const item1 = screen.getByRole("option", { name: "Item 1" });
226
+ expect(item1).toHaveFocus();
227
+ });
228
+
229
+ // Select the first item
230
+ await user.keyboard("{Enter}");
231
+ await user.keyboard("{ArrowDown}");
232
+ // select the second item
233
+ await user.keyboard(" ");
234
+
235
+ await waitFor(() => {
236
+ // Check that the second item is focused and selected
237
+ const item2 = screen.getByRole("option", { name: "Item 2" });
238
+ expect(item2).toHaveFocus();
239
+ expect(item2).toBeChecked();
240
+
241
+ // Check that the first item is still selected
242
+ const item1 = screen.getByRole("menuitem", { name: "Item 1" });
243
+ expect(item1).toBeChecked();
244
+ });
245
+
246
+ xit("should stay open after menu item selection", async () => {
247
+ const { user } = render(
248
+ <MultiSelectMenu
249
+ triggerElement={
250
+ <MenuToggleButton aria-label="testable menu" role="button">
251
+ Testable Menu
252
+ </MenuToggleButton>
253
+ }
254
+ >
255
+ <MenuContent>
256
+ <MenuGroup>
257
+ {menuItems.map((item) => (
258
+ <MenuItemSelectable
259
+ key={item.id}
260
+ id={item.id}
261
+ data-testid={item.id}
262
+ label={item.label}
263
+ >
264
+ {item.label}
265
+ </MenuItemSelectable>
266
+ ))}
267
+ </MenuGroup>
268
+ </MenuContent>
269
+ </MultiSelectMenu>
270
+ );
271
+
272
+ const menuContentContainer = screen.queryByRole("listbox");
273
+
274
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
275
+
276
+ const toggleButton = screen.getByRole("button", {
277
+ name: "testable menu",
278
+ });
279
+
280
+ // Open Menu
281
+ await user.click(toggleButton);
282
+
283
+ const item1 = screen.getByRole("option", { name: "Item 1" });
284
+
285
+ await waitFor(() => {
286
+ expect(item1).not.toHaveStyle("pointer-events: none");
287
+ });
288
+
289
+ // Select Item 1
290
+ await user.click(item1);
291
+
292
+ await waitFor(() => {
293
+ expect(menuContentContainer).toBeInTheDocument();
294
+ });
295
+ });
296
+
297
+ xit("Should work with a initially selected items", async () => {
298
+ const { user } = render(
299
+ <MultiSelectMenu
300
+ triggerElement={
301
+ <MenuToggleButton aria-label="testable menu" role="button">
302
+ Testable Menu
303
+ </MenuToggleButton>
304
+ }
305
+ defaultSelectedItems={[menuItems[1], menuItems[2]]}
306
+ >
307
+ <MenuContent>
308
+ <MenuGroup>
309
+ {menuItems.map((item) => (
310
+ <MenuItemSelectable
311
+ key={item.id}
312
+ id={item.id}
313
+ data-testid={item.id}
314
+ label={item.label}
315
+ >
316
+ {item.label}
317
+ </MenuItemSelectable>
318
+ ))}
319
+ </MenuGroup>
320
+ </MenuContent>
321
+ </MultiSelectMenu>
322
+ );
323
+
324
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
325
+
326
+ const toggleButton = screen.getByRole("button", {
327
+ name: "testable menu",
328
+ });
329
+
330
+ // Open Menu
331
+ await user.click(toggleButton);
332
+
333
+ const item1 = screen.getByRole("option", { name: "Item 1" });
334
+ const item2 = screen.getByRole("option", { name: "Item 2" });
335
+ const item3 = screen.getByRole("option", { name: "Item 3" });
336
+
337
+ await waitFor(() => {
338
+ expect(item1).not.toHaveStyle("pointer-events: none");
339
+ });
340
+
341
+ act(() => {
342
+ // Check that the second item is selected
343
+ expect(item2).toHaveAttribute("aria-checked", "true");
344
+ // Check that the third item is selected
345
+ expect(item3).toHaveAttribute("aria-checked", "true");
346
+ });
347
+ });
348
+ });
349
+ });
@@ -0,0 +1,78 @@
1
+ import * as React from "react";
2
+ import {
3
+ MultiSelectMenu,
4
+ MenuContent,
5
+ MenuHeader,
6
+ MenuFooter,
7
+ } from "../../index";
8
+
9
+ export const MultiSelectMenuTypeTest = () => (
10
+ <>
11
+ {/* Valid MultiSelectMenu with minimal required props */}
12
+ <MultiSelectMenu triggerElement={<>test</>}>Test</MultiSelectMenu>
13
+ {/* Valid MultiSelectMenu allows MenuContent by itself as a child */}
14
+ <MultiSelectMenu triggerElement={<>test</>}>
15
+ <MenuContent>Test</MenuContent>
16
+ </MultiSelectMenu>
17
+ {/* Valid MultiSelectMenu allows multiple children without fragment */}
18
+ <MultiSelectMenu triggerElement={<>test</>}>
19
+ <MenuHeader>Test</MenuHeader>
20
+ <MenuContent>Test</MenuContent>
21
+ <MenuFooter>Test</MenuFooter>
22
+ </MultiSelectMenu>
23
+ {/* Valid MultiSelectMenu accepts Downshift props and functions for useSelect */}
24
+ <MultiSelectMenu
25
+ triggerElement={<>test</>}
26
+ onSelectedItemChange={() => {}}
27
+ selectedItem={null}
28
+ stateReducer={(c) => c}
29
+ onStateChange={() => {}}
30
+ getA11yStatusMessage={() => ""}
31
+ isOpen
32
+ >
33
+ Test
34
+ </MultiSelectMenu>
35
+ {/* Valid MultiSelectMenu accepts Downshift props and functions for useMultipleSelection */}
36
+ <MultiSelectMenu
37
+ triggerElement={<>test</>}
38
+ onSelectedItemsChange={() => {}}
39
+ selectedItems={[]}
40
+ multiSelectStateReducer={(c) => c}
41
+ onMultiSelectStateChange={() => {}}
42
+ getA11yMultiStatusMessage={() => ""}
43
+ >
44
+ Test
45
+ </MultiSelectMenu>
46
+
47
+ {/* @ts-expect-error - triggerElement is a required prop */}
48
+ <MultiSelectMenu>Test</MultiSelectMenu>
49
+ <MultiSelectMenu
50
+ /* @ts-expect-error - triggerElement must be a valid react element */
51
+ triggerElement="test"
52
+ >
53
+ Test
54
+ </MultiSelectMenu>
55
+ {/* @ts-expect-error - when no children provided */}
56
+ <MultiSelectMenu triggerElement="test" />
57
+
58
+ <MultiSelectMenu
59
+ triggerElement={<>test</>}
60
+ /* @ts-expect-error - isOpen can't be a string */
61
+ isOpen="false"
62
+ /* @ts-expect-error - onSelectedItemChange can't be a string */
63
+ onSelectedItemChange="test"
64
+ /* @ts-expect-error - selectedItem can't be a string */
65
+ selectedItem="test"
66
+ /* @ts-expect-error - stateReducer must have a return value */
67
+ stateReducer={() => {}}
68
+ /* @ts-expect-error - onMultiSelectStateChange can't be a string */
69
+ onMultiSelectStateChange="test"
70
+ /* @ts-expect-error - selectedItems can't be a string */
71
+ selectedItems="test"
72
+ /* @ts-expect-error - multiSelectStateReducer must have a return value */
73
+ multiSelectStateReducer="test"
74
+ >
75
+ Test
76
+ </MultiSelectMenu>
77
+ </>
78
+ );
@@ -0,0 +1 @@
1
+ export * from "./MultiSelectMenu";
@@ -0,0 +1,23 @@
1
+ Feature: Single Select Menu
2
+
3
+ A Menu for selecting a single item from a list of items.
4
+
5
+ Rule: User Behavior
6
+ Background:
7
+ Given an end user is interacting with the component
8
+
9
+ Scenario: SingleSelectMenu sets a selected item
10
+ When an item is selected
11
+ Then a selectedItem is set
12
+
13
+ Rule: API
14
+ Background:
15
+ Given an engineer is implementing a SingleSelectMenu
16
+
17
+ Scenario: SingleSelectMenu extends Menu
18
+ When Menu features are used in SingleSelectMenu
19
+ Then the SingleSelectMenu responds as a Menu would
20
+
21
+ Scenario: SingleSelectMenu can be set to automatically close after selection
22
+ When the closeMenuOnSelection prop is set to true
23
+ Then the menu automatically closes after an item is selected
@@ -0,0 +1,56 @@
1
+ import React from "react";
2
+ import { useSelect, type UseSelectState } from "downshift";
3
+ import { useItemsFromChildren } from "../hooks/useItemsFromChildren";
4
+ import {
5
+ defaultUseSelectProps,
6
+ useSelectStateReducer,
7
+ } from "../reducers/useSelectStateReducer";
8
+ import { itemToString } from "../utils/itemToString";
9
+ import { MenuRoot } from "../MenuRoot";
10
+ import type { TypeDefaultItemProps } from "../types";
11
+ import type { TypeSingleSelectMenuProps } from "./SingleSelectMenuTypes";
12
+
13
+ export const SingleSelectMenu = ({
14
+ children,
15
+ stateReducer: externalStateReducer,
16
+ ...useSelectProps
17
+ }: TypeSingleSelectMenuProps) => {
18
+ /** Import the manipulated map of menu items from the context */
19
+ const { allMenuItems, menuItemsMap } = useItemsFromChildren({ children });
20
+
21
+ /**
22
+ * Destructure Downshift props and call the core useSelect with the new menu items and state handler
23
+ */
24
+ const { isOpen, ...useSelectReturnProps } = useSelect({
25
+ ...defaultUseSelectProps,
26
+ items: allMenuItems,
27
+ itemToString,
28
+ stateReducer: (state, actionAndChanges) => {
29
+ let newState: Partial<UseSelectState<TypeDefaultItemProps>>;
30
+ newState = useSelectStateReducer(state, actionAndChanges);
31
+ return externalStateReducer
32
+ ? externalStateReducer(state, {
33
+ ...actionAndChanges,
34
+ changes: newState,
35
+ })
36
+ : newState;
37
+ },
38
+ ...useSelectProps,
39
+ });
40
+
41
+ return (
42
+ <MenuRoot
43
+ {...{
44
+ isListbox: true,
45
+ items: allMenuItems,
46
+ itemsMap: menuItemsMap,
47
+ itemToString,
48
+ isOpen,
49
+ ...useSelectProps,
50
+ ...useSelectReturnProps,
51
+ }}
52
+ >
53
+ {children}
54
+ </MenuRoot>
55
+ );
56
+ };
@@ -0,0 +1,9 @@
1
+ import type { UseSelectProps } from "downshift";
2
+ import type { TypeMenuRootProps, TypeMenuRootOwnProps } from "../MenuRoot";
3
+ import type { TypeDefaultItemProps } from "../types";
4
+
5
+ export interface TypeSingleSelectMenuProps
6
+ extends TypeMenuRootOwnProps,
7
+ Partial<UseSelectProps<TypeDefaultItemProps>> {
8
+ children: TypeMenuRootProps["children"];
9
+ }