@sproutsocial/seeds-react-menu 1.3.1 → 1.4.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 +18 -0
- package/dist/esm/index.js +86 -69
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +86 -69
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/MenuContent/MenuContentTypes.ts +22 -1
- package/src/MenuContent/styles.tsx +20 -1
- package/src/MenuRoot/MenuRoot.tsx +2 -0
- package/src/MenuRoot/__tests__/MenuRoot.test.tsx +48 -1
- package/src/MenuToggleButton/MenuToggleButton.tsx +30 -11
- package/src/NestedMenu/NestedMenu.stories.tsx +35 -14
- package/src/NestedMenu/NestedMenu.tsx +13 -3
- package/src/NestedMenu/NestedMenuPopout.tsx +8 -2
- package/src/menuTestingUtils.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TypeBorderSystemProps,
|
|
3
|
+
TypeColorSystemProps,
|
|
4
|
+
TypeFlexboxSystemProps,
|
|
5
|
+
TypeGridSystemProps,
|
|
6
|
+
TypeLayoutSystemProps,
|
|
7
|
+
TypePositionSystemProps,
|
|
8
|
+
TypeSpaceSystemProps,
|
|
9
|
+
TypeStyledComponentsCommonProps,
|
|
10
|
+
} from "@sproutsocial/seeds-react-system-props";
|
|
1
11
|
import type { TypeMenuItemProps } from "../MenuItem";
|
|
2
12
|
import type { TypeChildrenOrItems } from "../types";
|
|
3
13
|
|
|
14
|
+
export interface TypeMenuContentContainerProps
|
|
15
|
+
extends Omit<React.ComponentPropsWithoutRef<"ul">, "children" | "color">,
|
|
16
|
+
TypeStyledComponentsCommonProps,
|
|
17
|
+
TypeBorderSystemProps,
|
|
18
|
+
TypeColorSystemProps,
|
|
19
|
+
TypeFlexboxSystemProps,
|
|
20
|
+
TypeGridSystemProps,
|
|
21
|
+
TypeLayoutSystemProps,
|
|
22
|
+
TypeSpaceSystemProps,
|
|
23
|
+
TypePositionSystemProps {}
|
|
24
|
+
|
|
4
25
|
export type TypeMenuContentProps<
|
|
5
26
|
I extends TypeMenuItemProps = TypeMenuItemProps
|
|
6
|
-
> =
|
|
27
|
+
> = TypeMenuContentContainerProps & {
|
|
7
28
|
innerRef?: React.Ref<HTMLUListElement>;
|
|
8
29
|
/* Custom component to display when no menu items are visible (e.g. "No results found"). */
|
|
9
30
|
emptyStateElement?: React.ReactNode;
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import styled from "styled-components";
|
|
2
|
+
import {
|
|
3
|
+
border,
|
|
4
|
+
color,
|
|
5
|
+
flexbox,
|
|
6
|
+
grid,
|
|
7
|
+
layout,
|
|
8
|
+
position,
|
|
9
|
+
space,
|
|
10
|
+
} from "styled-system";
|
|
11
|
+
import type { TypeMenuContentProps } from "./MenuContentTypes";
|
|
2
12
|
|
|
3
|
-
export const StyledMenuContent = styled.ul
|
|
13
|
+
export const StyledMenuContent = styled.ul<TypeMenuContentProps>`
|
|
4
14
|
margin: 0;
|
|
5
15
|
padding: 0;
|
|
16
|
+
overflow: auto;
|
|
6
17
|
|
|
7
18
|
&:focus {
|
|
8
19
|
outline: none;
|
|
9
20
|
box-shadow: none;
|
|
10
21
|
}
|
|
22
|
+
|
|
23
|
+
${border}
|
|
24
|
+
${color}
|
|
25
|
+
${flexbox}
|
|
26
|
+
${grid}
|
|
27
|
+
${layout}
|
|
28
|
+
${space}
|
|
29
|
+
${position}
|
|
11
30
|
`;
|
|
@@ -267,8 +267,11 @@ describe("MenuRoot", () => {
|
|
|
267
267
|
const contextProps: TypeMenuContextProps = {
|
|
268
268
|
toggleMenu: () => setIsOpen(!isOpen),
|
|
269
269
|
getToggleButtonProps: (props) => ({
|
|
270
|
-
onClick: () => setIsOpen(!isOpen),
|
|
271
270
|
...props,
|
|
271
|
+
onClick: () => {
|
|
272
|
+
props.onClick?.();
|
|
273
|
+
setIsOpen(!isOpen);
|
|
274
|
+
},
|
|
272
275
|
}),
|
|
273
276
|
isOpen,
|
|
274
277
|
};
|
|
@@ -300,6 +303,50 @@ describe("MenuRoot", () => {
|
|
|
300
303
|
expect(screen.getByText(randomText)).toBeInTheDocument();
|
|
301
304
|
});
|
|
302
305
|
|
|
306
|
+
it("calls user-supplied onClick when MenuToggleButton is clicked", async () => {
|
|
307
|
+
const userOnClick = jest.fn();
|
|
308
|
+
|
|
309
|
+
const TestCustomMenu = () => {
|
|
310
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
311
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
312
|
+
// @ts-ignore - mock contextProps may be imperfect
|
|
313
|
+
const contextProps: TypeMenuContextProps = {
|
|
314
|
+
toggleMenu: () => setIsOpen(!isOpen),
|
|
315
|
+
getToggleButtonProps: (props) => ({
|
|
316
|
+
...props,
|
|
317
|
+
onClick: (e) => {
|
|
318
|
+
props.onClick?.(e);
|
|
319
|
+
setIsOpen(!isOpen);
|
|
320
|
+
},
|
|
321
|
+
}),
|
|
322
|
+
isOpen,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<MenuRoot
|
|
327
|
+
menuToggleElement={
|
|
328
|
+
<MenuToggleButton onClick={userOnClick} appearance="secondary">
|
|
329
|
+
Open Menu
|
|
330
|
+
</MenuToggleButton>
|
|
331
|
+
}
|
|
332
|
+
{...testDefaultContext(contextProps)}
|
|
333
|
+
>
|
|
334
|
+
Menu Content
|
|
335
|
+
</MenuRoot>
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
await actRender(<TestCustomMenu />);
|
|
340
|
+
|
|
341
|
+
const button = screen.getByRole("button", { name: /open menu/i });
|
|
342
|
+
|
|
343
|
+
await act(async () => {
|
|
344
|
+
fireEvent.click(button);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(userOnClick).toHaveBeenCalledTimes(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
303
350
|
const contextTests: {
|
|
304
351
|
child: string;
|
|
305
352
|
TestComponent: React.FC;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Button, type TypeButtonProps } from "@sproutsocial/seeds-react-button";
|
|
2
2
|
import { useMenuToggleContext } from "../MenuContext";
|
|
3
|
+
import type React from "react";
|
|
3
4
|
|
|
4
5
|
export interface TypeMenuToggleButtonProps extends TypeButtonProps {}
|
|
5
6
|
|
|
@@ -13,17 +14,35 @@ export interface TypeMenuToggleButtonProps extends TypeButtonProps {}
|
|
|
13
14
|
export const MenuToggleButton = (props: TypeMenuToggleButtonProps) => {
|
|
14
15
|
const { getToggleButtonProps, isListbox, getDropdownProps, ref, ariaProps } =
|
|
15
16
|
useMenuToggleContext();
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
onClick: userOnClick,
|
|
20
|
+
onKeyDown: userOnKeyDown,
|
|
21
|
+
onBlur: userOnBlur,
|
|
22
|
+
...restProps
|
|
23
|
+
} = props;
|
|
24
|
+
|
|
25
|
+
const defaultDropdownProps = {
|
|
26
|
+
ref,
|
|
27
|
+
onClick: userOnClick,
|
|
28
|
+
onKeyDown: userOnKeyDown,
|
|
29
|
+
onBlur: userOnBlur,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const dropdownProps =
|
|
33
|
+
getDropdownProps?.({
|
|
34
|
+
...defaultDropdownProps,
|
|
35
|
+
preventKeyAction: true,
|
|
36
|
+
}) ?? defaultDropdownProps;
|
|
37
|
+
|
|
38
|
+
const toggleButtonProps = getToggleButtonProps?.({
|
|
39
|
+
...dropdownProps,
|
|
40
|
+
refKey: "innerRef",
|
|
41
|
+
...ariaProps,
|
|
42
|
+
...(isListbox ? {} : { role: "button", "aria-haspopup": "menu" }),
|
|
43
|
+
});
|
|
44
|
+
|
|
16
45
|
return (
|
|
17
|
-
<Button
|
|
18
|
-
appearance="secondary"
|
|
19
|
-
{...getToggleButtonProps?.({
|
|
20
|
-
// Decide if we want the preventKeyAction stuff. Maybe create a prop to allow for this to be overriden.
|
|
21
|
-
...(getDropdownProps?.({ ref, preventKeyAction: true }) || { ref }),
|
|
22
|
-
refKey: "innerRef",
|
|
23
|
-
...ariaProps,
|
|
24
|
-
...(isListbox ? {} : { role: "button", "aria-haspopup": "menu" }),
|
|
25
|
-
})}
|
|
26
|
-
{...props}
|
|
27
|
-
/>
|
|
46
|
+
<Button appearance="secondary" {...toggleButtonProps} {...restProps} />
|
|
28
47
|
);
|
|
29
48
|
};
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
MultiSelectMenu,
|
|
14
14
|
NestedMenu,
|
|
15
15
|
MenuSearchInput,
|
|
16
|
+
MenuSearchTokenInput,
|
|
16
17
|
} from "../index";
|
|
17
18
|
import { NestedMenuItem } from "./NestedMenuItem";
|
|
18
19
|
import { NestedMenuContent } from "./NestedMenuContent";
|
|
@@ -89,11 +90,16 @@ const menus = {
|
|
|
89
90
|
"books-action": {
|
|
90
91
|
MenuComponent: ActionMenu,
|
|
91
92
|
menuProps: {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
children: (
|
|
94
|
+
<NestedMenuContent
|
|
95
|
+
menuItems={TEST_BOOKS.map((book) => ({
|
|
96
|
+
id: book.id,
|
|
97
|
+
children: book.title,
|
|
98
|
+
childMenuId: "edit-book",
|
|
99
|
+
}))}
|
|
100
|
+
// maxHeight="300px"
|
|
101
|
+
/>
|
|
102
|
+
),
|
|
97
103
|
},
|
|
98
104
|
menuHeaderProps: {
|
|
99
105
|
title: "Manage Books",
|
|
@@ -103,24 +109,36 @@ const menus = {
|
|
|
103
109
|
MenuComponent: MultiSelectMenu,
|
|
104
110
|
menuProps: {
|
|
105
111
|
itemToString: (item) => item.menuItemProps.children,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
children: (
|
|
113
|
+
<NestedMenuContent
|
|
114
|
+
menuItems={TEST_BOOKS.map((book) => ({
|
|
115
|
+
id: book.id,
|
|
116
|
+
children: book.title,
|
|
117
|
+
}))}
|
|
118
|
+
// maxHeight="300px"
|
|
119
|
+
/>
|
|
120
|
+
),
|
|
110
121
|
},
|
|
111
122
|
menuHeaderProps: {
|
|
112
123
|
title: "Select Books",
|
|
113
|
-
children:
|
|
124
|
+
children: (
|
|
125
|
+
<MenuSearchTokenInput id="nested-search" name="nested-search" />
|
|
126
|
+
),
|
|
114
127
|
},
|
|
115
128
|
},
|
|
116
129
|
"books-select": {
|
|
117
130
|
MenuComponent: SingleSelectMenu,
|
|
118
131
|
menuProps: {
|
|
119
132
|
itemToString: (item) => item.menuItemProps.children,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
children: (
|
|
134
|
+
<NestedMenuContent
|
|
135
|
+
menuItems={TEST_BOOKS.map((book) => ({
|
|
136
|
+
id: book.id,
|
|
137
|
+
children: book.title,
|
|
138
|
+
}))}
|
|
139
|
+
// maxHeight="300px"
|
|
140
|
+
/>
|
|
141
|
+
),
|
|
124
142
|
},
|
|
125
143
|
menuHeaderProps: {
|
|
126
144
|
title: "Select One Book",
|
|
@@ -167,6 +185,9 @@ export const NestedMenuMultiLevelStory: Story = {
|
|
|
167
185
|
menuToggleElement={
|
|
168
186
|
<MenuToggleButton width="200px">Open Menu</MenuToggleButton>
|
|
169
187
|
}
|
|
188
|
+
popoutProps={{
|
|
189
|
+
placement: "top",
|
|
190
|
+
}}
|
|
170
191
|
/>
|
|
171
192
|
</Box>
|
|
172
193
|
</MotionConfig>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useId, useEffect, useState } from "react";
|
|
2
|
-
import { AnimatePresence,
|
|
2
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
3
3
|
import { useSelect } from "downshift";
|
|
4
4
|
|
|
5
5
|
import { useDownshiftDefaults } from "../hooks/useDownshiftDefaults";
|
|
@@ -31,7 +31,10 @@ export const NestedMenu = <
|
|
|
31
31
|
onBackButtonClick,
|
|
32
32
|
backArrowLabel,
|
|
33
33
|
menuToggleElement,
|
|
34
|
-
popoutProps
|
|
34
|
+
popoutProps: {
|
|
35
|
+
scheduleUpdateRef: externalPopoutUpdateRef,
|
|
36
|
+
...restPopoutProps
|
|
37
|
+
} = {},
|
|
35
38
|
width = "300px",
|
|
36
39
|
stateReducer: rootExternalStateReducer,
|
|
37
40
|
id: externalNestedMenuId,
|
|
@@ -96,6 +99,8 @@ export const NestedMenu = <
|
|
|
96
99
|
useState<TypeMenuContext>(defaultMenuContext);
|
|
97
100
|
const [currentMenuId, setCurrentMenuId] = useState(initialMenuId);
|
|
98
101
|
const [isAdding, setIsAdding] = useState(false);
|
|
102
|
+
const [popoutUpdateRef, setPopoutUpdateRef] =
|
|
103
|
+
useState<() => void | undefined>();
|
|
99
104
|
|
|
100
105
|
const setSelectedMenuId = (id: string) => {
|
|
101
106
|
setIsAdding(true);
|
|
@@ -108,6 +113,7 @@ export const NestedMenu = <
|
|
|
108
113
|
if (selectedMenuPath.length > 0) {
|
|
109
114
|
const newMenuId = selectedMenuPath[selectedMenuPath.length - 1];
|
|
110
115
|
setCurrentMenuId(newMenuId);
|
|
116
|
+
popoutUpdateRef?.();
|
|
111
117
|
}
|
|
112
118
|
}, [selectedMenuPath.join("-")]);
|
|
113
119
|
|
|
@@ -272,7 +278,11 @@ export const NestedMenu = <
|
|
|
272
278
|
}
|
|
273
279
|
menuToggleElement={menuToggleElement}
|
|
274
280
|
width={width}
|
|
275
|
-
{
|
|
281
|
+
scheduleUpdateRef={(callback) => {
|
|
282
|
+
setPopoutUpdateRef(callback);
|
|
283
|
+
externalPopoutUpdateRef?.(callback);
|
|
284
|
+
}}
|
|
285
|
+
{...restPopoutProps}
|
|
276
286
|
/>
|
|
277
287
|
</NestedMenuProvider>
|
|
278
288
|
);
|
|
@@ -16,6 +16,7 @@ export const NestedMenuPopout = ({
|
|
|
16
16
|
content,
|
|
17
17
|
menuToggleElement,
|
|
18
18
|
width,
|
|
19
|
+
onClose,
|
|
19
20
|
...popoutProps
|
|
20
21
|
}: Omit<TypeMenuPopoutProps, "children"> & {
|
|
21
22
|
menuToggleElement: TypeMenuRootProps["menuToggleElement"];
|
|
@@ -25,9 +26,13 @@ export const NestedMenuPopout = ({
|
|
|
25
26
|
|
|
26
27
|
return (
|
|
27
28
|
<MenuPopout
|
|
28
|
-
|
|
29
|
+
// TODO: add after prop is added to Popout
|
|
30
|
+
// lockPlacement
|
|
29
31
|
// Reset the selected menu path after the close animation completes
|
|
30
|
-
onClose={() =>
|
|
32
|
+
onClose={() => {
|
|
33
|
+
onClose?.();
|
|
34
|
+
setTimeout(() => resetSelectedMenuPath?.(), 100);
|
|
35
|
+
}}
|
|
31
36
|
isOpen={!!rootMenuContextProps.isOpen}
|
|
32
37
|
openMenu={rootMenuContextProps.openMenu}
|
|
33
38
|
closeMenu={rootMenuContextProps.closeMenu}
|
|
@@ -37,6 +42,7 @@ export const NestedMenuPopout = ({
|
|
|
37
42
|
{typeof content === "function" ? content(props) : content}
|
|
38
43
|
</StyledDiv>
|
|
39
44
|
)}
|
|
45
|
+
{...popoutProps}
|
|
40
46
|
>
|
|
41
47
|
{(toggleProps) => (
|
|
42
48
|
<MenuToggleProvider
|
package/src/menuTestingUtils.tsx
CHANGED
|
@@ -29,9 +29,9 @@ export const testDefaultContext = ({
|
|
|
29
29
|
// @ts-expect-error - mocked function does not match real type
|
|
30
30
|
getToggleButtonProps: jest.fn(({ ref, refKey = "ref", ...props } = {}) => ({
|
|
31
31
|
onKeyDown: jest.fn(),
|
|
32
|
-
...getToggleButtonProps?.({ ref, refKey, ...props }),
|
|
33
32
|
...(ref ? { [refKey]: ref } : {}),
|
|
34
33
|
...props,
|
|
34
|
+
...getToggleButtonProps?.({ ref, refKey, ...props }),
|
|
35
35
|
})),
|
|
36
36
|
// @ts-expect-error - mocked function does not match real type
|
|
37
37
|
getMenuProps: jest.fn((props) => props),
|