@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
@@ -356,5 +356,43 @@ describe("Menu", () => {
356
356
  // Check that stateReducer is called
357
357
  await waitFor(() => expect(mockStateReducer).toHaveBeenCalled());
358
358
  });
359
+
360
+ it("should render the MenuItems when provided as children", async () => {
361
+ const { user } = render(
362
+ <Component menuToggleElement={toggleElement}>
363
+ <MenuContent>
364
+ <MenuItem id="item-1">Item 1</MenuItem>
365
+ <MenuItem id="item-2">Item 2</MenuItem>
366
+ </MenuContent>
367
+ </Component>
368
+ );
369
+
370
+ // Open Menu
371
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
372
+ await user.click(menuTrigger);
373
+
374
+ // Check if both items are rendered
375
+ await screen.findByText("Item 1");
376
+ await screen.findByText("Item 2");
377
+ });
378
+
379
+ it("should render the MenuItems when provided as items", async () => {
380
+ const { user } = render(
381
+ <Component menuToggleElement={toggleElement}>
382
+ <MenuContent>
383
+ <MenuItem id="item-1">Item 1</MenuItem>
384
+ <MenuItem id="item-2">Item 2</MenuItem>
385
+ </MenuContent>
386
+ </Component>
387
+ );
388
+
389
+ // Open Menu
390
+ const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
391
+ await user.click(menuTrigger);
392
+
393
+ // Check if both items are rendered
394
+ await screen.findByText("Item 1");
395
+ await screen.findByText("Item 2");
396
+ });
359
397
  });
360
398
  });
@@ -0,0 +1,107 @@
1
+ import React from "react";
2
+ import type {
3
+ UseComboboxProps,
4
+ UseSelectProps,
5
+ UseMultipleSelectionProps,
6
+ } from "downshift";
7
+ import type { TypeInternalItemProps } from "../types";
8
+ import type {
9
+ TypeMenuContextBaseProps,
10
+ TypeDownshiftMultiSelectProps,
11
+ } from "../MenuContext";
12
+ import {
13
+ useComboboxStateReducer,
14
+ useComboboxStateReducerForMulti,
15
+ } from "../reducers/useComboboxStateReducer";
16
+ import {
17
+ useSelectStateReducer,
18
+ useSelectStateReducerForMulti,
19
+ } from "../reducers/useSelectStateReducer";
20
+ import { getMultiSelectOverrides } from "../utils/getMultiSelectOverrides";
21
+
22
+ export const defaultUseSelectProps: Partial<
23
+ UseSelectProps<TypeInternalItemProps>
24
+ > = {
25
+ stateReducer: useSelectStateReducer,
26
+ };
27
+
28
+ export const defaultUseComboboxProps: Partial<
29
+ UseComboboxProps<TypeInternalItemProps>
30
+ > = {
31
+ stateReducer: useComboboxStateReducer,
32
+ };
33
+
34
+ export const defaultUseSelectPropsForMulti: Partial<
35
+ UseSelectProps<TypeInternalItemProps>
36
+ > = {
37
+ stateReducer: useSelectStateReducerForMulti,
38
+ };
39
+
40
+ export const defaultUseComboboxPropsForMulti: Partial<
41
+ UseComboboxProps<TypeInternalItemProps>
42
+ > = {
43
+ stateReducer: useComboboxStateReducerForMulti,
44
+ };
45
+
46
+ export interface TypeUseDownshiftDefaultsReturn
47
+ extends Pick<
48
+ Required<TypeMenuContextBaseProps>,
49
+ "hiddenItemsMap" | "setHiddenItemsMap"
50
+ > {
51
+ defaultDownshiftProps: {
52
+ select: Partial<UseSelectProps<TypeInternalItemProps>>;
53
+ combobox: Partial<UseComboboxProps<TypeInternalItemProps>>;
54
+ multi: Partial<UseMultipleSelectionProps<TypeInternalItemProps>>;
55
+ };
56
+ }
57
+
58
+ export const useDownshiftDefaults = ({
59
+ isCombobox = false,
60
+ isMulti = false,
61
+ multiSelectProps = {},
62
+ }: {
63
+ isCombobox?: boolean;
64
+ isMulti?: boolean;
65
+ multiSelectProps?: TypeDownshiftMultiSelectProps;
66
+ } = {}): TypeUseDownshiftDefaultsReturn => {
67
+ const [hiddenItemsMap, setHiddenItemsMap] = React.useState<
68
+ Record<string, boolean>
69
+ >({});
70
+ const isItemDisabled = React.useCallback<
71
+ Required<UseSelectProps<TypeInternalItemProps>>["isItemDisabled"]
72
+ >(
73
+ (item) => Boolean(item.disabled || hiddenItemsMap[item.id]),
74
+ [hiddenItemsMap]
75
+ );
76
+
77
+ return {
78
+ hiddenItemsMap,
79
+ setHiddenItemsMap,
80
+ defaultDownshiftProps: {
81
+ select: !isCombobox
82
+ ? {
83
+ // disabling while item.hidden so that hidden items are ignored by downshift
84
+ isItemDisabled,
85
+ ...(isMulti
86
+ ? defaultUseSelectPropsForMulti
87
+ : defaultUseSelectProps),
88
+ }
89
+ : {},
90
+ combobox: isCombobox
91
+ ? {
92
+ // disabling while item.hidden so that hidden items are ignored by downshift
93
+ isItemDisabled,
94
+ ...(isMulti
95
+ ? defaultUseComboboxPropsForMulti
96
+ : defaultUseComboboxProps),
97
+ }
98
+ : {},
99
+ multi: isMulti
100
+ ? {
101
+ // handles the prop name mapping for multi select
102
+ ...getMultiSelectOverrides(multiSelectProps),
103
+ }
104
+ : {},
105
+ },
106
+ };
107
+ };
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ import type { TypeMenuContext } from "../MenuContext";
3
+ import type { TypeInternalItemProps } from "../types";
4
+ import { itemToString as defaultItemToString } from "../utils/downshiftDefaults";
5
+
6
+ export type TypeGetIsItemVisibleFn = (
7
+ item: TypeMenuContext["items"][number],
8
+ inputValue: string
9
+ ) => boolean;
10
+
11
+ /**
12
+ * @description A function to get the default getIsItemVisible function
13
+ *
14
+ * @param itemToString - A function to convert an item to a string
15
+ *
16
+ * @returns The default getIsItemVisible function
17
+ */
18
+ export const getDefaultGetIsItemVisible =
19
+ (
20
+ itemToString: TypeMenuContext["itemToString"] = defaultItemToString
21
+ ): TypeGetIsItemVisibleFn =>
22
+ (item, inputValue) =>
23
+ itemToString(item).toLowerCase().includes(inputValue.toLowerCase());
24
+
25
+ export type TypeGetShouldFilterFn = (arg: { inputValue?: string }) => boolean;
26
+
27
+ export const defaultGetShouldFilter: TypeGetShouldFilterFn = ({ inputValue }) =>
28
+ Boolean(inputValue);
29
+
30
+ export interface TypeItemFilteringProps {
31
+ allMenuItems: TypeInternalItemProps[];
32
+ itemToString?: TypeMenuContext["itemToString"];
33
+ getIsItemVisible?: TypeGetIsItemVisibleFn;
34
+ getShouldFilter?: TypeGetShouldFilterFn;
35
+ setHiddenItemsMap?: TypeMenuContext["setHiddenItemsMap"];
36
+ }
37
+
38
+ export const useItemFiltering = ({
39
+ allMenuItems,
40
+ itemToString,
41
+ getIsItemVisible = getDefaultGetIsItemVisible(itemToString),
42
+ getShouldFilter = defaultGetShouldFilter,
43
+ setHiddenItemsMap: setHiddenItemsMapProp,
44
+ }: TypeItemFilteringProps) => {
45
+ const [hiddenItemsMap, setHiddenItemsMap] = React.useState<
46
+ Required<TypeMenuContext>["hiddenItemsMap"]
47
+ >({});
48
+ const updateFilteredItems = React.useCallback(
49
+ ({ inputValue }) => {
50
+ const newHiddenItems = getShouldFilter({ inputValue })
51
+ ? allMenuItems.reduce((map, item) => {
52
+ const isItemVisible = getIsItemVisible(item, inputValue);
53
+ return isItemVisible ? map : { ...map, [item.id]: true };
54
+ }, {} as typeof hiddenItemsMap)
55
+ : {};
56
+ setHiddenItemsMap(newHiddenItems);
57
+ setHiddenItemsMapProp?.(newHiddenItems);
58
+ },
59
+ [allMenuItems, getIsItemVisible, getShouldFilter]
60
+ );
61
+
62
+ return {
63
+ getIsItemVisible,
64
+ updateFilteredItems,
65
+ };
66
+ };
@@ -0,0 +1,279 @@
1
+ import React, { type ReactNode, useMemo } from "react";
2
+ import { MenuItem, type TypeMenuItemProps } from "../MenuItem";
3
+ import type {
4
+ TypeSubmenuItemProps,
5
+ TypeSubmenuValidMenuComponentProps,
6
+ } from "../SubmenuItem";
7
+ import type { TypeMenuContext } from "../MenuContext";
8
+ import type { TypeMenuGroupProps, TypeMenuGroupBaseProps } from "../MenuGroup";
9
+ import type { TypeMenuRootProps } from "../MenuRoot";
10
+ import type {
11
+ TypeNotEmptyChildren,
12
+ TypeChildrenOrItems,
13
+ TypeInternalItemProps,
14
+ } from "../types";
15
+ import { MenuContent, type TypeMenuContentProps } from "../MenuContent";
16
+
17
+ export interface TypeMapMenuItemsProps<
18
+ I extends TypeMenuItemProps = TypeMenuItemProps
19
+ > {
20
+ menuItems: I[];
21
+ MenuItemComponent?: React.ComponentType<I>;
22
+ }
23
+ export type TypeItemElement<I extends TypeMenuItemProps = TypeMenuItemProps> =
24
+ React.ReactElement<
25
+ | TypeMenuItemProps
26
+ | TypeSubmenuItemProps<I, TypeSubmenuValidMenuComponentProps<I>>
27
+ >;
28
+ export type TypeMenuGroupElement<
29
+ I extends TypeMenuItemProps = TypeMenuItemProps
30
+ > = React.ReactElement<TypeMenuGroupProps<I>>;
31
+ export type TypeMenuContentElement<
32
+ I extends TypeMenuItemProps = TypeMenuItemProps
33
+ > = React.ReactElement<TypeMenuContentProps<I>>;
34
+
35
+ export interface TypeMenuChildren {
36
+ type: "item" | "group" | "content";
37
+ element: TypeItemElement | TypeMenuGroupElement | TypeMenuContentElement;
38
+ }
39
+
40
+ // https://stackoverflow.com/questions/69119016/find-specific-children-components-regardless-of-depth-in-react
41
+ const walkAllChildren = (
42
+ children: ReactNode,
43
+ callback: (element: ReactNode, parents: readonly ReactNode[]) => void
44
+ ) => {
45
+ const walk = (element: ReactNode, parents: readonly ReactNode[]) => {
46
+ if (element === null || element === undefined) return;
47
+ callback(element, parents);
48
+ const newParents = [...parents, element];
49
+ const children = (element as any).props?.children;
50
+ React.Children.toArray(children).forEach((child) => {
51
+ walk(child, newParents);
52
+ });
53
+ };
54
+ const parents = [];
55
+ React.Children.toArray(children).forEach((child) => {
56
+ walk(child, parents);
57
+ });
58
+ };
59
+
60
+ const getComponentName = (element: ReactNode): string | undefined => {
61
+ if (
62
+ element &&
63
+ typeof element === "object" &&
64
+ "type" in element &&
65
+ typeof element.type !== "string" &&
66
+ typeof element.type.name === "string"
67
+ ) {
68
+ return element.type.name;
69
+ }
70
+ };
71
+
72
+ const addMenuItem = ({
73
+ itemProps,
74
+ allMenuItems,
75
+ menuItemsMap,
76
+ }: {
77
+ itemProps: Omit<TypeInternalItemProps, "index">;
78
+ allMenuItems: TypeMenuContext["items"];
79
+ menuItemsMap: TypeMenuContext["itemsMap"];
80
+ }) => {
81
+ const item = {
82
+ ...itemProps,
83
+ index: allMenuItems.length,
84
+ };
85
+ allMenuItems.push(item);
86
+ menuItemsMap[`${item.id}`] = item;
87
+ };
88
+
89
+ const addItemsFromGroup = <I extends TypeMenuItemProps = TypeMenuItemProps>({
90
+ groupElement: menuGroup,
91
+ allMenuItems,
92
+ menuItemsMap,
93
+ }: {
94
+ groupElement: TypeMenuGroupElement<I>;
95
+ allMenuItems: TypeMenuContext["items"];
96
+ menuItemsMap: TypeMenuContext["itemsMap"];
97
+ }) => {
98
+ const menuItems = menuGroup.props.menuItems;
99
+ if (menuItems) {
100
+ menuItems.forEach((menuItem) => {
101
+ addMenuItem({
102
+ itemProps: {
103
+ id: menuItem.id,
104
+ disabled: menuItem.disabled,
105
+ menuItemProps: menuItem,
106
+ menuGroupProps: menuGroup.props,
107
+ },
108
+ allMenuItems,
109
+ menuItemsMap,
110
+ });
111
+ });
112
+ } else if (menuGroup.props.children) {
113
+ let children = menuGroup.props.children as TypeItemElement[];
114
+ // If there is only one child, it will be an object. Make it an array.
115
+ if (!Array.isArray(children)) {
116
+ children = [children];
117
+ }
118
+ children.forEach((menuItem) => {
119
+ addMenuItem({
120
+ itemProps: {
121
+ id: menuItem.props.id,
122
+ disabled: menuItem.props.disabled,
123
+ menuItemProps: menuItem.props,
124
+ menuGroupProps: menuGroup.props,
125
+ },
126
+ allMenuItems,
127
+ menuItemsMap,
128
+ });
129
+ });
130
+ }
131
+ };
132
+
133
+ const addItemsFromContent = <I extends TypeMenuItemProps = TypeMenuItemProps>({
134
+ contentElement: menuContent,
135
+ allMenuItems,
136
+ menuItemsMap,
137
+ }: {
138
+ contentElement: TypeMenuContentElement<I>;
139
+ allMenuItems: TypeMenuContext["items"];
140
+ menuItemsMap: TypeMenuContext["itemsMap"];
141
+ }) => {
142
+ /**
143
+ * No need to add items inside of MenuContent if it does not have menuItems
144
+ * since we already traversed the children and menuContentProps are not needed.
145
+ */
146
+ if (!("menuItems" in menuContent.props)) return;
147
+ // Add items from menuItems prop if it isn't empty
148
+ if (menuContent.props.menuItems) {
149
+ menuContent.props.menuItems.forEach((menuItem) => {
150
+ addMenuItem({
151
+ itemProps: {
152
+ id: menuItem.id,
153
+ disabled: menuItem.disabled,
154
+ menuItemProps: menuItem,
155
+ },
156
+ allMenuItems,
157
+ menuItemsMap,
158
+ });
159
+ });
160
+ }
161
+ };
162
+
163
+ const setMenuItemsFromChildren = ({
164
+ children,
165
+ allMenuItems,
166
+ menuItemsMap,
167
+ }: {
168
+ children: ReactNode;
169
+ allMenuItems: TypeMenuContext["items"];
170
+ menuItemsMap: TypeMenuContext["itemsMap"];
171
+ }) => {
172
+ walkAllChildren(children, (element) => {
173
+ switch (getComponentName(element)) {
174
+ case "MenuItem":
175
+ case "SubmenuItem":
176
+ case "NestedMenuItem":
177
+ // Cast element to the TypeItemElement since we know it's one of the three.
178
+ // This is needed for the addMenuItem function.
179
+ const menuItem = element as TypeItemElement;
180
+ // Items in groups would have already been added to the map.
181
+ if (menuItemsMap[`${menuItem.props.id}`]) return;
182
+ addMenuItem({
183
+ itemProps: {
184
+ id: menuItem.props.id,
185
+ disabled: menuItem.props.disabled,
186
+ menuItemProps: menuItem.props,
187
+ },
188
+ allMenuItems,
189
+ menuItemsMap,
190
+ });
191
+ break;
192
+ case "MenuGroup":
193
+ addItemsFromGroup({
194
+ // Cast element to the TypeMenuGroupElement since we know it's a group and is needed for addItemsFromGroup.
195
+ groupElement: element as TypeMenuGroupElement,
196
+ allMenuItems,
197
+ menuItemsMap,
198
+ });
199
+ break;
200
+ case "MenuContent":
201
+ case "NestedMenuContent":
202
+ addItemsFromContent({
203
+ // Cast element to the TypeMenuContentElement since we know it's a content and is needed for addItemsFromContent.
204
+ contentElement: element as TypeMenuContentElement,
205
+ allMenuItems,
206
+ menuItemsMap,
207
+ });
208
+ break;
209
+ default:
210
+ break;
211
+ }
212
+ });
213
+ };
214
+
215
+ // Returns a fragment containing a `MenuItemComponent` for each item in `menuItems`.
216
+ export const mapMenuItems = <I extends TypeMenuItemProps = TypeMenuItemProps>({
217
+ menuItems,
218
+ MenuItemComponent = MenuItem,
219
+ }: TypeMapMenuItemsProps<I>) => {
220
+ return (
221
+ <>
222
+ {menuItems.map((menuItemProps) => (
223
+ <MenuItemComponent key={menuItemProps.id} {...menuItemProps} />
224
+ ))}
225
+ </>
226
+ );
227
+ };
228
+
229
+ type TypeUseMenuChildrenProps<I extends TypeMenuItemProps = TypeMenuItemProps> =
230
+ {
231
+ children?: TypeMenuRootProps["children"];
232
+ menuItems?: I[];
233
+ MenuItemComponent?: React.ComponentType<I>;
234
+ };
235
+
236
+ export const getChildrenFromMenuItems = <
237
+ I extends TypeMenuItemProps = TypeMenuItemProps
238
+ >({
239
+ menuItems,
240
+ MenuItemComponent,
241
+ }: {
242
+ menuItems: I[];
243
+ MenuItemComponent?: React.ComponentType<I>;
244
+ }) => (
245
+ <MenuContent menuItems={menuItems} MenuItemComponent={MenuItemComponent} />
246
+ );
247
+
248
+ export const useMenuChildren = <
249
+ I extends TypeMenuItemProps = TypeMenuItemProps
250
+ >({
251
+ children: childrenProp,
252
+ menuItems,
253
+ MenuItemComponent,
254
+ }: TypeUseMenuChildrenProps<I>) => {
255
+ const { allMenuItems, menuItemsMap, children } = useMemo(() => {
256
+ let children: TypeNotEmptyChildren = <></>;
257
+ const menuItemsMap: TypeMenuContext["itemsMap"] = {};
258
+ const allMenuItems: TypeMenuContext["items"] = [];
259
+
260
+ // If an menuItems prop was passed to the Menu, we can bypass the children traversal.
261
+ if (menuItems && menuItems.length) {
262
+ menuItems.forEach((menuItem) => {
263
+ addMenuItem({ itemProps: menuItem, allMenuItems, menuItemsMap });
264
+ });
265
+ children = getChildrenFromMenuItems({ menuItems, MenuItemComponent });
266
+ } else if (childrenProp) {
267
+ children = childrenProp as TypeNotEmptyChildren;
268
+ setMenuItemsFromChildren({
269
+ children: childrenProp,
270
+ allMenuItems,
271
+ menuItemsMap,
272
+ });
273
+ }
274
+
275
+ return { allMenuItems, menuItemsMap, children };
276
+ }, [childrenProp, menuItems, MenuItemComponent]);
277
+
278
+ return { allMenuItems, menuItemsMap, children };
279
+ };
package/src/index.ts CHANGED
@@ -7,15 +7,21 @@ export * from "./MenuGroup";
7
7
  export * from "./MenuRoot";
8
8
  export * from "./MenuLabel";
9
9
  export * from "./MenuToggleButton";
10
+ export * from "./MenuSearchInput";
11
+ export * from "./MenuTokenInput";
12
+ export * from "./MenuSearchTokenInput";
13
+ export * from "./SubmenuItem";
10
14
 
11
15
  // Menus
12
16
  export * from "./ActionMenu";
13
17
  export * from "./SingleSelectMenu";
14
18
  export * from "./MultiSelectMenu";
15
19
  export * from "./Autocomplete";
20
+ export * from "./NestedMenu";
16
21
 
17
22
  // Utilities
18
23
  export * from "./MenuContext";
19
- export * from "./hooks/useItemsFromChildren";
24
+ export * from "./NestedMenu/NestedMenuContext";
25
+ export * from "./hooks/useMenuChildren";
20
26
  export * from "./reducers/useSelectStateReducer";
21
27
  export * from "./types";
@@ -1,15 +1,19 @@
1
1
  import React from "react";
2
2
  import {
3
- MenuContext,
3
+ MenuContentContext,
4
4
  MenuRoot,
5
+ checkIfItemSelected,
6
+ getSelectedItemIds,
7
+ useMenu,
5
8
  type TypeMenuContextProps,
6
9
  type TypeMenuContext,
7
10
  type TypeMenuRootProps,
8
11
  } from "./index";
9
12
 
10
- export const testDefaultContext = (
11
- context: Partial<TypeMenuContextProps> = {}
12
- ): TypeMenuContext => ({
13
+ export const testDefaultContext = ({
14
+ getToggleButtonProps,
15
+ ...context
16
+ }: Partial<TypeMenuContextProps> = {}): TypeMenuContext => ({
13
17
  items: [],
14
18
  itemsMap: context.items
15
19
  ? context.items.reduce((map, item) => {
@@ -17,10 +21,16 @@ export const testDefaultContext = (
17
21
  return map;
18
22
  }, {} as TypeMenuContext["itemsMap"])
19
23
  : {},
20
- // TODO: Make type on MenuContext more agnostic for other use cases
24
+ // TODO: Make type on MenuContentContext more agnostic for other use cases
21
25
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
22
26
  // @ts-expect-error - mocked function does not match real type
23
27
  getItemProps: (props) => ({ role: "option", ...props }),
28
+ // @ts-expect-error - mocked function does not match real type
29
+ getToggleButtonProps: ({ ref, refKey, ...props } = {}) => ({
30
+ ...getToggleButtonProps?.({ ref, refKey, ...props }),
31
+ ...(ref ? { [refKey]: ref } : {}),
32
+ ...props,
33
+ }),
24
34
  isListbox: true,
25
35
  ...context,
26
36
  });
@@ -53,12 +63,12 @@ export const TestWithMenuRoot = ({
53
63
 
54
64
  /**
55
65
  * TestWithMockedMenu
56
- * Given a children and context, render the children with the raw MenuContext with a mocked value.
66
+ * Given a children and context, render the children with the raw MenuContentContext with a mocked value.
57
67
  * Used for unit testing components without the real MenuProvider or MenuRoot.
58
68
  *
59
- * @param children - react children to be loaded inside of the MenuContext.Provider
69
+ * @param children - react children to be loaded inside of the MenuContentContext.Provider
60
70
  * @param context - mocked context props to override the testDefaultContext
61
- * @returns - rendered component with mocked MenuContext
71
+ * @returns - rendered component with mocked MenuContentContext
62
72
  */
63
73
  export const TestWithMockedMenu = ({
64
74
  children,
@@ -67,9 +77,10 @@ export const TestWithMockedMenu = ({
67
77
  children: React.ReactNode;
68
78
  context: Partial<TypeMenuContextProps>;
69
79
  }) => {
80
+ const menuContext = useMenu(testDefaultContext(context));
70
81
  return (
71
- <MenuContext.Provider value={testDefaultContext(context)}>
82
+ <MenuContentContext.Provider value={menuContext}>
72
83
  {children}
73
- </MenuContext.Provider>
84
+ </MenuContentContext.Provider>
74
85
  );
75
86
  };
@@ -0,0 +1,23 @@
1
+ import { useCombobox, useSelect } from "downshift";
2
+
3
+ export const nestedMenuStateReducer = (_state, actionAndChanges) => {
4
+ const { type, changes } = actionAndChanges;
5
+ const hasChildMenu = !!changes?.selectedItem?.menuItemProps?.childMenuId;
6
+ if (!hasChildMenu) {
7
+ return changes;
8
+ }
9
+ switch (type) {
10
+ case useSelect.stateChangeTypes.ToggleButtonBlur:
11
+ case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
12
+ case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
13
+ case useSelect.stateChangeTypes.ItemClick:
14
+ case useCombobox.stateChangeTypes.ItemClick:
15
+ return {
16
+ ...changes,
17
+ isOpen: true,
18
+ highlightedIndex: 0,
19
+ };
20
+ default:
21
+ return changes;
22
+ }
23
+ };
@@ -1,11 +1,13 @@
1
1
  import { useCombobox } from "downshift";
2
2
  import type { UseComboboxProps } from "downshift";
3
- import type { TypeDefaultItemProps } from "../types";
3
+ import type { TypeInternalItemProps } from "../types";
4
+ import { reduceReducers } from "../utils/reduceReducers";
4
5
 
5
6
  export const useComboboxStateReducer: Required<
6
- UseComboboxProps<TypeDefaultItemProps>
7
+ UseComboboxProps<TypeInternalItemProps>
7
8
  >["stateReducer"] = (state, actionAndChanges) => {
8
9
  const { type, changes } = actionAndChanges;
10
+
9
11
  switch (type) {
10
12
  /** If downshift is blurred by tabbing within the menu, stay open */
11
13
  case useCombobox.stateChangeTypes.InputBlur:
@@ -21,9 +23,25 @@ export const useComboboxStateReducer: Required<
21
23
  }
22
24
  };
23
25
 
24
- export const defaultUseComboboxProps: Partial<
25
- UseComboboxProps<TypeDefaultItemProps>
26
- > = {
27
- // disabling while item.hidden so that hidden items are ignored by downshift
28
- isItemDisabled: (item) => Boolean(item.disabled || item.hidden),
29
- };
26
+ export const useComboboxStateReducerForMulti: Required<
27
+ UseComboboxProps<TypeInternalItemProps>
28
+ >["stateReducer"] = reduceReducers(
29
+ useComboboxStateReducer,
30
+ (state, actionAndChanges) => {
31
+ const { changes, type } = actionAndChanges;
32
+
33
+ switch (type) {
34
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
35
+ case useCombobox.stateChangeTypes.ItemClick:
36
+ case useCombobox.stateChangeTypes.InputBlur:
37
+ return {
38
+ ...changes,
39
+ ...(changes.selectedItem && {
40
+ inputValue: "",
41
+ }),
42
+ };
43
+ default:
44
+ return changes;
45
+ }
46
+ }
47
+ );