@sproutsocial/seeds-react-menu 1.0.1 → 1.2.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 (44) hide show
  1. package/.turbo/turbo-build.log +86 -10
  2. package/CHANGELOG.md +44 -0
  3. package/dist/esm/index.js +537 -126
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/index.d.mts +47 -16
  6. package/dist/index.d.ts +47 -16
  7. package/dist/index.js +602 -184
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/ActionMenu/ActionMenu.tsx +4 -3
  11. package/src/ActionMenu/ActionMenuTypes.ts +5 -4
  12. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +1 -1
  13. package/src/Autocomplete/Autocomplete.stories.tsx +124 -32
  14. package/src/Autocomplete/AutocompleteSelect.tsx +24 -0
  15. package/src/Autocomplete/AutocompleteTypes.ts +5 -2
  16. package/src/Autocomplete/MultiAutocomplete.tsx +3 -2
  17. package/src/Autocomplete/SingleAutocomplete.tsx +4 -4
  18. package/src/Autocomplete/index.ts +2 -0
  19. package/src/ListboxToggleButton.tsx +65 -0
  20. package/src/MenuContent/MenuContentTypes.ts +1 -2
  21. package/src/MenuContent/styles.tsx +6 -9
  22. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +1 -1
  23. package/src/MenuItem/__tests__/MenuItem.test.tsx +1 -3
  24. package/src/MenuRoot/MenuRoot.tsx +95 -15
  25. package/src/MultiSelectMenu/MultiSelectMenu.stories.tsx +0 -1
  26. package/src/MultiSelectMenu/MultiSelectMenu.tsx +3 -2
  27. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +5 -3
  28. package/src/NestedMenu/NestedMenu.feature +1 -1
  29. package/src/NestedMenu/NestedMenu.stories.tsx +25 -7
  30. package/src/NestedMenu/NestedMenu.tsx +217 -43
  31. package/src/NestedMenu/NestedMenuContext.tsx +16 -0
  32. package/src/NestedMenu/NestedMenuHeader.tsx +19 -6
  33. package/src/NestedMenu/NestedMenuItem.tsx +11 -8
  34. package/src/NestedMenu/NestedMenuPopout.tsx +55 -0
  35. package/src/NestedMenu/NestedMenuRoot.tsx +27 -0
  36. package/src/NestedMenu/NestedMenuTypes.ts +3 -2
  37. package/src/NestedMenu/__tests__/NestedMenu.test.tsx +210 -19
  38. package/src/SingleSelectMenu/SingleSelectMenu.stories.tsx +0 -1
  39. package/src/SingleSelectMenu/SingleSelectMenu.tsx +3 -2
  40. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +5 -3
  41. package/src/__tests__/Menu.test.tsx +1 -1
  42. package/src/index.ts +1 -0
  43. package/src/storybookUtilities/menu-storybook-components.tsx +11 -31
  44. package/src/types.ts +5 -1
@@ -25,6 +25,7 @@ export const MultiSelectMenu = <
25
25
  MenuItemComponent,
26
26
  onSelectedItemChange: externalOnSelectedItemChange,
27
27
  stateReducer: externalStateReducer,
28
+ MenuRootComponent = MenuRoot,
28
29
  getA11yMultiStatusMessage,
29
30
  onMultiSelectStateChange,
30
31
  multiSelectStateReducer,
@@ -98,7 +99,7 @@ export const MultiSelectMenu = <
98
99
  });
99
100
 
100
101
  return (
101
- <MenuRoot
102
+ <MenuRootComponent
102
103
  {...{
103
104
  isListbox: true,
104
105
  items: allMenuItems,
@@ -120,6 +121,6 @@ export const MultiSelectMenu = <
120
121
  }}
121
122
  >
122
123
  {children}
123
- </MenuRoot>
124
+ </MenuRootComponent>
124
125
  );
125
126
  };
@@ -1,13 +1,15 @@
1
- import type { TypeMenuRootOwnProps, TypeMenuRootProps } from "../MenuRoot";
2
1
  import type {
3
2
  TypeDownshiftSelectProps,
4
3
  TypeDownshiftMultiSelectProps,
5
4
  } from "../MenuContext";
6
5
  import type { TypeMenuItemProps } from "../MenuItem";
7
- import type { TypeChildrenOrItems } from "../types";
6
+ import type {
7
+ TypeChildrenOrItems,
8
+ TypeHigherOrderComponentProps,
9
+ } from "../types";
8
10
 
9
11
  export interface TypeMultiSelectMenuBaseProps
10
- extends TypeMenuRootOwnProps,
12
+ extends TypeHigherOrderComponentProps,
11
13
  TypeDownshiftSelectProps,
12
14
  TypeDownshiftMultiSelectProps {}
13
15
 
@@ -105,5 +105,5 @@ Feature: NestedMenu
105
105
  | propName | propType |
106
106
  | --------------------- | -------------------------------------------------------------------------------- |
107
107
  | menus | Record<string, {MenuComponent, menuProps, MenuHeaderComponent, menuHeaderProps}> |
108
- | selectedMenuId | string |
108
+ | initialMenuId | string |
109
109
  | onBackButtonClick | function |
@@ -12,9 +12,11 @@ import {
12
12
  MenuToggleButton,
13
13
  MultiSelectMenu,
14
14
  NestedMenu,
15
+ MenuSearchInput,
15
16
  } from "../index";
16
17
  import { NestedMenuItem } from "./NestedMenuItem";
17
18
  import { NestedMenuContent } from "./NestedMenuContent";
19
+ import { MotionConfig } from "motion/react";
18
20
 
19
21
  type CustomNestedMenuArgs = React.ComponentProps<typeof NestedMenu>;
20
22
 
@@ -61,7 +63,7 @@ const menus = {
61
63
  MenuComponent: ActionMenu,
62
64
  menuProps: {
63
65
  children: (
64
- <NestedMenuContent>
66
+ <NestedMenuContent id="MenuContentWithChildren">
65
67
  <NestedMenuItem
66
68
  id="item-2"
67
69
  children="Books as ActionMenu"
@@ -100,6 +102,7 @@ const menus = {
100
102
  "books-multiselect": {
101
103
  MenuComponent: MultiSelectMenu,
102
104
  menuProps: {
105
+ itemToString: (item) => item.menuItemProps.children,
103
106
  menuItems: TEST_BOOKS.map((book) => ({
104
107
  id: book.id,
105
108
  children: book.title,
@@ -107,11 +110,13 @@ const menus = {
107
110
  },
108
111
  menuHeaderProps: {
109
112
  title: "Select Books",
113
+ children: <MenuSearchInput id="nested-search" name="nested-search" />,
110
114
  },
111
115
  },
112
116
  "books-select": {
113
117
  MenuComponent: SingleSelectMenu,
114
118
  menuProps: {
119
+ itemToString: (item) => item.menuItemProps.children,
115
120
  menuItems: TEST_BOOKS.map((book) => ({
116
121
  id: book.id,
117
122
  children: book.title,
@@ -119,6 +124,7 @@ const menus = {
119
124
  },
120
125
  menuHeaderProps: {
121
126
  title: "Select One Book",
127
+ children: <MenuSearchInput id="nested-search" name="nested-search" />,
122
128
  },
123
129
  },
124
130
  "edit-book": {
@@ -146,12 +152,24 @@ export const NestedMenuMultiLevelStory: Story = {
146
152
  name: "Multi Level NestedMenu",
147
153
  render: (args) => {
148
154
  return (
149
- <NestedMenu
150
- initialMenuId="root"
151
- menus={menus}
152
- backArrowLabel="Go Back"
153
- menuToggleElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
154
- />
155
+ <MotionConfig reducedMotion="user">
156
+ <Box
157
+ display="flex"
158
+ alignItems="center"
159
+ justifyContent="center"
160
+ height="100vh"
161
+ width="100vw"
162
+ >
163
+ <NestedMenu
164
+ initialMenuId="root"
165
+ menus={menus}
166
+ backArrowLabel="Go Back"
167
+ menuToggleElement={
168
+ <MenuToggleButton width="200px">Open Menu</MenuToggleButton>
169
+ }
170
+ />
171
+ </Box>
172
+ </MotionConfig>
155
173
  );
156
174
  },
157
175
  };
@@ -1,15 +1,27 @@
1
- import React, { useState } from "react";
1
+ import { useId, useEffect, useState } from "react";
2
+ import { AnimatePresence, LayoutGroup, motion } from "motion/react";
3
+ import { useSelect } from "downshift";
4
+
5
+ import { useDownshiftDefaults } from "../hooks/useDownshiftDefaults";
6
+ import { reduceReducers } from "../utils/reduceReducers";
7
+ import { nestedMenuStateReducer } from "../reducers/nestedMenuStateReducer";
8
+ import {
9
+ defaultMenuContext,
10
+ type TypeMenuContext,
11
+ type TypeDownshiftSelectProps,
12
+ } from "../MenuContext";
2
13
  import type { TypeMenuItemProps } from "../MenuItem";
3
14
  import type {
4
15
  TypeNestedMenuItemProps,
5
16
  TypeNestedMenuProps,
6
17
  } from "./NestedMenuTypes";
7
- import { reduceReducers } from "../utils/reduceReducers";
8
- import { NestedMenuHeader } from "./NestedMenuHeader";
9
- import { nestedMenuStateReducer } from "../reducers/nestedMenuStateReducer";
10
18
  import { NestedMenuContent } from "./NestedMenuContent";
11
19
  import { NestedMenuProvider } from "./NestedMenuContext";
20
+ import { NestedMenuHeader } from "./NestedMenuHeader";
12
21
  import { NestedMenuItem } from "./NestedMenuItem";
22
+ import { MOTION_DURATION_FAST } from "@sproutsocial/seeds-motion/unitless";
23
+ import { NestedMenuRoot } from "./NestedMenuRoot";
24
+ import { NestedMenuPopout } from "./NestedMenuPopout";
13
25
 
14
26
  export const NestedMenu = <
15
27
  I extends TypeMenuItemProps = TypeNestedMenuItemProps
@@ -19,16 +31,93 @@ export const NestedMenu = <
19
31
  onBackButtonClick,
20
32
  backArrowLabel,
21
33
  menuToggleElement,
34
+ popoutProps = {},
35
+ width = "300px",
36
+ stateReducer: rootExternalStateReducer,
37
+ id: externalNestedMenuId,
38
+ ...useSelectProps
22
39
  }: TypeNestedMenuProps<I>) => {
40
+ /**
41
+ * Handling the Open/Close and accessibility of the NestedMenu itself
42
+ */
43
+ const { defaultDownshiftProps, ...restDefaults } = useDownshiftDefaults({
44
+ isCombobox: false,
45
+ });
46
+ const uniqueId = useId();
47
+ const nestedMenuId = externalNestedMenuId ?? `nested-menu-${uniqueId}`;
48
+ const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
49
+ const useSelectReturnProps = useSelect({
50
+ ...defaultDownshiftProps.select,
51
+ id: nestedMenuId,
52
+ items: [],
53
+ stateReducer: reduceReducers(
54
+ defaultDownshiftProps.select.stateReducer,
55
+ rootExternalStateReducer
56
+ ),
57
+ ...useSelectProps,
58
+ });
59
+
60
+ const rootMenuContextProps = {
61
+ ...useSelectProps,
62
+ ...useSelectReturnProps,
63
+ ...restDefaults,
64
+ };
65
+
66
+ useEffect(() => {
67
+ if (!useSelectReturnProps.isOpen) {
68
+ setHasLoadedOnce(false);
69
+ } else {
70
+ !hasLoadedOnce && setHasLoadedOnce(true);
71
+ }
72
+ }, [useSelectReturnProps.isOpen]);
73
+
74
+ // onNestedIsOpenChange handles closing the popout based on the selected menu's built in open/close logic.
75
+ const onNestedIsOpenChange: TypeDownshiftSelectProps["onIsOpenChange"] = ({
76
+ isOpen: newIsOpen,
77
+ }) => {
78
+ // Close the popout when a child menu's close logic is triggered
79
+ !newIsOpen && useSelectReturnProps.closeMenu?.();
80
+ };
81
+
82
+ const nestedMenuPropsFromRoot: Partial<TypeDownshiftSelectProps> = {
83
+ // The selected menu needs to receive this id so that the toggle element can be properly associated with it.
84
+ id: nestedMenuId,
85
+ onIsOpenChange: onNestedIsOpenChange,
86
+ };
87
+
88
+ /**
89
+ * Handling the state of the NestedMenu content
90
+ */
23
91
  const [selectedMenuPath, setSelectedMenuPath] = useState([initialMenuId]);
24
- const selectedMenuId = selectedMenuPath[selectedMenuPath.length - 1];
92
+ const [activeMenuContext, setActiveMenuContext] =
93
+ useState<TypeMenuContext>(defaultMenuContext);
94
+ const [currentMenuId, setCurrentMenuId] = useState(initialMenuId);
95
+ const [isAdding, setIsAdding] = useState(false);
25
96
 
26
97
  const setSelectedMenuId = (id: string) => {
98
+ setIsAdding(true);
27
99
  setSelectedMenuPath((currentPath) => [...currentPath, id]);
28
100
  };
101
+
102
+ // Set the currentMenuId reactively after the selectedMenuPath changes, so that
103
+ // isAdding has time to be set before the animation starts.
104
+ useEffect(() => {
105
+ if (selectedMenuPath.length > 0) {
106
+ const newMenuId = selectedMenuPath[selectedMenuPath.length - 1];
107
+ setCurrentMenuId(newMenuId);
108
+ }
109
+ }, [selectedMenuPath.join("-")]);
110
+
111
+ const resetSelectedMenuPath = () => {
112
+ setSelectedMenuPath([initialMenuId]);
113
+ setCurrentMenuId(initialMenuId);
114
+ };
115
+
29
116
  const goBack = () => {
117
+ setIsAdding(false);
30
118
  setSelectedMenuPath((currentPath) => currentPath.slice(0, -1));
31
- onBackButtonClick?.(selectedMenuId);
119
+ // user-defined callback
120
+ onBackButtonClick?.(currentMenuId);
32
121
  };
33
122
 
34
123
  const {
@@ -36,14 +125,14 @@ export const NestedMenu = <
36
125
  menuProps,
37
126
  MenuHeaderComponent = NestedMenuHeader,
38
127
  menuHeaderProps = {},
39
- } = menus[selectedMenuId] || {};
128
+ } = menus[currentMenuId] || {};
129
+
40
130
  const {
41
131
  children,
42
132
  menuItems,
43
133
  MenuItemComponent,
44
134
  stateReducer: externalStateReducer,
45
- onStateChange: externalOnStateChange,
46
- ...props
135
+ ...restMenuProps
47
136
  } = menuProps || {};
48
137
 
49
138
  const stateReducer = reduceReducers(
@@ -51,52 +140,137 @@ export const NestedMenu = <
51
140
  externalStateReducer
52
141
  );
53
142
 
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
- }
143
+ const animationSpeed = MOTION_DURATION_FAST;
144
+
145
+ const contentVariants = {
146
+ arrive: (custom = { hasLoadedOnce: false, isAdding: false }) => ({
147
+ opacity: 0,
148
+ // Start from left if is first render of root, else use default
149
+ x: !custom?.hasLoadedOnce ? "200%" : custom?.isAdding ? "200%" : "-200%",
150
+ }),
151
+ enter: (custom = { hasLoadedOnce: false }) => ({
152
+ opacity: 1,
153
+ x: 0,
154
+ transition: {
155
+ type: "tween",
156
+ duration: animationSpeed,
157
+ ease: "circOut",
158
+ delay: !custom.hasLoadedOnce ? animationSpeed : 0,
159
+ },
160
+ }),
161
+ leave: (custom = { hasLoadedOnce: false, isAdding: false }) => ({
162
+ opacity: 0,
163
+ x: custom?.isAdding ? "-200%" : "200%",
164
+ transition: {
165
+ type: "tween",
166
+ duration: animationSpeed,
167
+ ease: "circIn",
168
+ },
169
+ }),
170
+ };
171
+
172
+ // This is suggested even though they are exactly the same
173
+ const menuVariants = {
174
+ arrive: (custom = { hasLoadedOnce: false, isAdding: false }) => ({
175
+ opacity: 0,
176
+ // Start from left if is first render of root, else use default
177
+ x: !custom?.hasLoadedOnce ? "200%" : custom?.isAdding ? "200%" : "-200%",
178
+ }),
179
+ enter: (custom = { hasLoadedOnce: false }) => ({
180
+ opacity: 1,
181
+ x: 0,
182
+ transition: {
183
+ type: "tween",
184
+ duration: animationSpeed,
185
+ ease: "circOut",
186
+ delay: !custom.hasLoadedOnce ? animationSpeed : 0,
187
+ },
188
+ }),
189
+ leave: (custom = { hasLoadedOnce: false, isAdding: false }) => ({
190
+ opacity: 0,
191
+ x: custom?.isAdding ? "-200%" : "200%",
192
+ transition: {
193
+ type: "tween",
194
+ duration: animationSpeed,
195
+ ease: "circIn",
196
+ },
197
+ }),
63
198
  };
64
199
 
65
200
  const nestedMenuProps = {
201
+ ...nestedMenuPropsFromRoot,
66
202
  menuToggleElement,
67
203
  stateReducer,
68
- onStateChange,
69
- initialIsOpen: selectedMenuPath.length > 1,
204
+ MenuRootComponent: NestedMenuRoot,
205
+ // This ensures that onNestedIsOpenChange will be triggered if the selected menu attempts to close itself.
206
+ initialIsOpen: true,
70
207
  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
- </>
208
+ // Handles the smooth animating of the content
209
+ <AnimatePresence mode="wait">
210
+ <motion.div
211
+ key={currentMenuId}
212
+ variants={contentVariants}
213
+ initial="arrive"
214
+ animate="enter"
215
+ exit="leave"
216
+ custom={{ isAdding, hasLoadedOnce }}
217
+ >
218
+ {MenuHeaderComponent && (
219
+ <MenuHeaderComponent
220
+ backArrowLabel={backArrowLabel}
221
+ {...menuHeaderProps}
222
+ />
223
+ )}
224
+ {/*children takes precedence over menuItems if both are passed.*/}
225
+ {children && children}
226
+ {menuItems && !children && (
227
+ <NestedMenuContent
228
+ menuItems={menuItems}
229
+ MenuItemComponent={MenuItemComponent ?? NestedMenuItem}
230
+ />
231
+ )}
232
+ </motion.div>
233
+ </AnimatePresence>
88
234
  ),
89
- ...props,
235
+ ...restMenuProps,
90
236
  };
91
237
 
92
238
  return (
93
239
  <NestedMenuProvider
94
- selectedMenuPath={selectedMenuPath}
95
- setSelectedMenuPath={setSelectedMenuPath}
96
- setSelectedMenuId={setSelectedMenuId}
97
- goBack={goBack}
240
+ {...{
241
+ selectedMenuPath,
242
+ setSelectedMenuPath,
243
+ setSelectedMenuId,
244
+ resetSelectedMenuPath,
245
+ goBack,
246
+ rootMenuContextProps,
247
+ activeMenuContext,
248
+ setActiveMenuContext,
249
+ animationSpeed,
250
+ }}
98
251
  >
99
- {MenuComponent && <MenuComponent {...nestedMenuProps} />}
252
+ <NestedMenuPopout
253
+ content={
254
+ MenuComponent && (
255
+ // Handles the rerender of the component
256
+ <AnimatePresence mode="wait">
257
+ <motion.div
258
+ key={`${currentMenuId}-content`}
259
+ variants={menuVariants}
260
+ initial="arrive"
261
+ animate="enter"
262
+ exit="leave"
263
+ custom={{ isAdding, hasLoadedOnce }}
264
+ >
265
+ <MenuComponent {...nestedMenuProps} />
266
+ </motion.div>
267
+ </AnimatePresence>
268
+ )
269
+ }
270
+ menuToggleElement={menuToggleElement}
271
+ width={width}
272
+ {...popoutProps}
273
+ />
100
274
  </NestedMenuProvider>
101
275
  );
102
276
  };
@@ -1,18 +1,34 @@
1
1
  import React, { createContext, useContext } from "react";
2
2
  import type { TypeNotEmptyChildren } from "../types";
3
+ import {
4
+ defaultMenuContext,
5
+ type TypeMenuContext,
6
+ type TypeMenuContextProps,
7
+ } from "../MenuContext";
8
+ import { MOTION_DURATION_FAST } from "@sproutsocial/seeds-motion/unitless";
3
9
 
4
10
  export interface TypeNestedMenuContext {
5
11
  selectedMenuPath: string[];
6
12
  setSelectedMenuId?: (id: string) => void;
7
13
  setSelectedMenuPath?: (path: string[]) => void;
14
+ resetSelectedMenuPath?: () => void;
8
15
  goBack?: () => void;
16
+ rootMenuContextProps: Omit<TypeMenuContextProps, "items">;
17
+ activeMenuContext: TypeMenuContext;
18
+ setActiveMenuContext: (context: TypeMenuContext) => void;
19
+ animationSpeed: number;
9
20
  }
10
21
 
11
22
  const defaultNestedMenuContext: TypeNestedMenuContext = {
12
23
  selectedMenuPath: [],
13
24
  setSelectedMenuId: () => {},
14
25
  setSelectedMenuPath: () => {},
26
+ resetSelectedMenuPath: () => {},
15
27
  goBack: () => {},
28
+ rootMenuContextProps: {},
29
+ activeMenuContext: defaultMenuContext,
30
+ setActiveMenuContext: () => {},
31
+ animationSpeed: MOTION_DURATION_FAST,
16
32
  };
17
33
 
18
34
  export interface TypeNestedMenuProviderProps extends TypeNestedMenuContext {
@@ -3,21 +3,34 @@ import { useNestedMenuContext } from "./NestedMenuContext";
3
3
  import { Button } from "@sproutsocial/seeds-react-button";
4
4
  import { Icon } from "@sproutsocial/seeds-react-icon";
5
5
  import type { TypeNestedMenuHeaderProps } from "./NestedMenuTypes";
6
+ import { AnimatePresence, motion } from "motion/react";
6
7
 
7
8
  export const NestedMenuHeader = ({
8
9
  backArrowLabel,
9
10
  ...props
10
11
  }: TypeNestedMenuHeaderProps) => {
11
- const { goBack, selectedMenuPath } = useNestedMenuContext();
12
+ const { goBack, selectedMenuPath, animationSpeed } = useNestedMenuContext();
12
13
  const showBackArrow = selectedMenuPath.length > 1;
13
14
  return (
14
15
  <MenuHeader
15
16
  leftAction={
16
- showBackArrow ? (
17
- <Button onClick={goBack} ariaLabel={backArrowLabel}>
18
- <Icon name="arrow-left" />
19
- </Button>
20
- ) : undefined
17
+ <AnimatePresence mode="wait">
18
+ <motion.div
19
+ key={`${selectedMenuPath}-header--button`}
20
+ initial={{ opacity: 0 }}
21
+ animate={{ opacity: 1 }}
22
+ exit={{
23
+ opacity: 0,
24
+ transition: { duration: animationSpeed },
25
+ }}
26
+ >
27
+ {showBackArrow ? (
28
+ <Button onClick={goBack} ariaLabel={backArrowLabel}>
29
+ <Icon name="arrow-left" />
30
+ </Button>
31
+ ) : undefined}
32
+ </motion.div>
33
+ </AnimatePresence>
21
34
  }
22
35
  {...props}
23
36
  />
@@ -13,14 +13,17 @@ const NestedMenuItem = ({
13
13
  const { setSelectedMenuId } = useNestedMenuContext();
14
14
  // This method of passing onClick is necessary because passing undefined for onClick
15
15
  // behaves differently from not passing anything at all.
16
- const onClickProps = childMenuId
17
- ? {
18
- onClick: (e) => {
19
- onClickProp?.(e);
20
- setSelectedMenuId?.(childMenuId);
21
- },
22
- }
23
- : {};
16
+ const onClickProps =
17
+ childMenuId || onClickProp
18
+ ? {
19
+ onClick: (e) => {
20
+ onClickProp?.(e);
21
+ if (childMenuId) {
22
+ setSelectedMenuId?.(childMenuId);
23
+ }
24
+ },
25
+ }
26
+ : {};
24
27
 
25
28
  return (
26
29
  <MenuItem {...onClickProps} {...rest}>
@@ -0,0 +1,55 @@
1
+ import { useNestedMenuContext } from "./NestedMenuContext";
2
+ import {
3
+ MenuPopout,
4
+ type TypeMenuPopoutProps,
5
+ type TypeMenuRootProps,
6
+ } from "../MenuRoot";
7
+ import { MenuToggleProvider } from "../MenuContext";
8
+ import { motion } from "motion/react";
9
+ import styled from "styled-components";
10
+
11
+ const StyledDiv = styled(motion.div)`
12
+ overflow: hidden;
13
+ `;
14
+
15
+ export const NestedMenuPopout = ({
16
+ content,
17
+ menuToggleElement,
18
+ width,
19
+ ...popoutProps
20
+ }: Omit<TypeMenuPopoutProps, "children"> & {
21
+ menuToggleElement: TypeMenuRootProps["menuToggleElement"];
22
+ }) => {
23
+ const { activeMenuContext, rootMenuContextProps, resetSelectedMenuPath } =
24
+ useNestedMenuContext();
25
+
26
+ return (
27
+ <MenuPopout
28
+ {...popoutProps}
29
+ // Reset the selected menu path after the close animation completes
30
+ onClose={() => setTimeout(() => resetSelectedMenuPath?.(), 100)}
31
+ isOpen={!!rootMenuContextProps.isOpen}
32
+ openMenu={rootMenuContextProps.openMenu}
33
+ closeMenu={rootMenuContextProps.closeMenu}
34
+ width={width}
35
+ content={(props) => (
36
+ <StyledDiv layout="position">
37
+ {typeof content === "function" ? content(props) : content}
38
+ </StyledDiv>
39
+ )}
40
+ >
41
+ {(toggleProps) => (
42
+ <MenuToggleProvider
43
+ // rootMenuContextProps overrides activeMenuContext since it handles open/close logic
44
+ menuContext={{
45
+ ...activeMenuContext,
46
+ ...rootMenuContextProps,
47
+ ...toggleProps,
48
+ }}
49
+ >
50
+ {menuToggleElement}
51
+ </MenuToggleProvider>
52
+ )}
53
+ </MenuPopout>
54
+ );
55
+ };
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { type TypeMenuRootProps } from "../MenuRoot";
3
+ import { MenuContentProvider, useMenu } from "../MenuContext";
4
+ import { useNestedMenuContext } from "./NestedMenuContext";
5
+
6
+ export const NestedMenuRoot = ({
7
+ children,
8
+ menuToggleElement,
9
+ ...contextProps
10
+ }: TypeMenuRootProps) => {
11
+ const menuContext = useMenu(contextProps);
12
+ const { setActiveMenuContext, selectedMenuPath } = useNestedMenuContext();
13
+
14
+ React.useEffect(() => {
15
+ setActiveMenuContext(menuContext);
16
+ }, [
17
+ selectedMenuPath[selectedMenuPath.length - 1],
18
+ menuContext.selectedItem,
19
+ menuContext.selectedItems?.length,
20
+ ]);
21
+
22
+ return (
23
+ <MenuContentProvider menuContext={menuContext}>
24
+ {children}
25
+ </MenuContentProvider>
26
+ );
27
+ };
@@ -4,7 +4,9 @@ import type { TypeSingleSelectMenuProps } from "../SingleSelectMenu";
4
4
  import type { TypeMenuItemProps } from "../MenuItem";
5
5
  import type { TypeMenuRootProps } from "../MenuRoot";
6
6
  import type { TypeMenuHeaderProps } from "../MenuHeader";
7
+ import type { TypeActionMenuBaseProps } from "../ActionMenu";
7
8
  import type { TypeChildrenOrItems } from "../types";
9
+ import type { TypePopoutProps } from "@sproutsocial/seeds-react-popout";
8
10
 
9
11
  export interface TypeNestedMenuMapProps<
10
12
  P,
@@ -30,9 +32,8 @@ export interface TypeNestedMenuItemProps extends TypeMenuItemProps {
30
32
 
31
33
  export interface TypeNestedMenuProps<
32
34
  I extends TypeMenuItemProps = TypeNestedMenuItemProps
33
- > {
35
+ > extends Omit<TypeActionMenuBaseProps, "MenuRootComponent"> {
34
36
  initialMenuId: string;
35
- menuToggleElement: TypeMenuRootProps["menuToggleElement"];
36
37
  onBackButtonClick?: (menuId: string) => void;
37
38
  /** Sets the default localized label for the back arrow in all child menus.
38
39
  * This label can be overridden by including backArrowLabel in a menu's menuHeaderProps. */