@sproutsocial/seeds-react-menu 0.2.0 → 0.2.1
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/.eslintignore +6 -0
- package/.eslintrc.js +6 -2
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +6 -0
- package/dist/esm/index.js +34 -19
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -14
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/ActionMenu/ActionMenu.tsx +1 -0
- package/src/MenuContent/MenuContentTypes.ts +1 -2
- package/src/MenuContext.tsx +1 -6
- package/src/MenuGroup/MenuGroupTypes.ts +1 -0
- package/src/MenuGroup/__tests__/MenuGroup.test.tsx +1 -0
- package/src/MenuHeader/__tests__/MenuHeader.typetest.tsx +1 -0
- package/src/MenuItem/MenuItem.tsx +4 -0
- package/src/MenuItem/styles.tsx +2 -0
- package/src/MenuItem/useOptionProps.tsx +1 -0
- package/src/MenuRoot/MenuRoot.feature +20 -10
- package/src/MenuRoot/MenuRoot.tsx +2 -1
- package/src/MenuRoot/__tests__/MenuRoot.test.tsx +165 -31
- package/src/MultiSelectMenu/MultiSelectMenu.feature +2 -13
- package/src/MultiSelectMenu/MultiSelectMenu.tsx +1 -0
- package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +141 -140
- package/src/SingleSelectMenu/SingleSelectMenu.tsx +1 -0
- package/src/hooks/useItemsFromChildren.tsx +1 -0
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutsocial/seeds-react-menu",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Seeds React Menu",
|
|
5
5
|
"author": "Sprout Social, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"clean:modules": "rm -rf node_modules",
|
|
15
15
|
"typecheck": "tsc --noEmit",
|
|
16
16
|
"test": "jest",
|
|
17
|
-
"test:watch": "jest --watch --coverage=false"
|
|
17
|
+
"test:watch": "jest --watch --coverage=false",
|
|
18
|
+
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
|
|
19
|
+
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
|
|
18
20
|
},
|
|
19
21
|
"dependencies": {
|
|
20
22
|
"@sproutsocial/seeds-react-box": "*",
|
|
@@ -30,7 +32,7 @@
|
|
|
30
32
|
"motion": "^11.11.17"
|
|
31
33
|
},
|
|
32
34
|
"devDependencies": {
|
|
33
|
-
"
|
|
35
|
+
"eslint-config-seeds": "*",
|
|
34
36
|
"@sproutsocial/seeds-react-testing-library": "*",
|
|
35
37
|
"@sproutsocial/seeds-testing": "*",
|
|
36
38
|
"@types/react": "^18.0.0",
|
|
@@ -111,6 +111,7 @@ export const ActionMenu = ({
|
|
|
111
111
|
onStateChange: (changes) => handleStateChange(changes, currentSelectedItem),
|
|
112
112
|
stateReducer: (state, actionAndChanges) => {
|
|
113
113
|
let newState: Partial<UseSelectState<TypeDefaultItemProps>>;
|
|
114
|
+
// eslint-disable-next-line prefer-const, react-hooks/rules-of-hooks
|
|
114
115
|
newState = useSelectStateReducer(state, actionAndChanges);
|
|
115
116
|
return externalStateReducer
|
|
116
117
|
? externalStateReducer(state, {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import React from "react";
|
|
2
1
|
import { type HTMLMotionProps } from "motion/react";
|
|
3
|
-
import type { ReactNode
|
|
2
|
+
import type { ReactNode } from "react";
|
|
4
3
|
|
|
5
4
|
export interface TypeMenuContentProps extends HTMLMotionProps<"ul"> {
|
|
6
5
|
children: ReactNode;
|
package/src/MenuContext.tsx
CHANGED
|
@@ -44,6 +44,7 @@ describe("MenuGroup", () => {
|
|
|
44
44
|
const menuGroup = screen.getByRole("group", { name: menuTitle });
|
|
45
45
|
expect(menuGroup).toBeInTheDocument();
|
|
46
46
|
expect(
|
|
47
|
+
// eslint-disable-next-line testing-library/prefer-presence-queries
|
|
47
48
|
screen.queryByRole("heading", { name: menuTitle })
|
|
48
49
|
).toBeInTheDocument();
|
|
49
50
|
});
|
|
@@ -6,6 +6,7 @@ export const MenuHeaderTypeTest = () => (
|
|
|
6
6
|
title="Menu Header"
|
|
7
7
|
leftAction={<div>left action</div>}
|
|
8
8
|
rightAction={<div>right action</div>}
|
|
9
|
+
// eslint-disable-next-line react/no-children-prop
|
|
9
10
|
children={<div>children</div>}
|
|
10
11
|
/>
|
|
11
12
|
{/* left and right action accept strings */}
|
|
@@ -17,9 +17,12 @@ export const MenuItem = ({
|
|
|
17
17
|
children,
|
|
18
18
|
onClick,
|
|
19
19
|
onKeyDown,
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
20
21
|
disabled = false,
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
21
23
|
ref,
|
|
22
24
|
href,
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
23
26
|
color,
|
|
24
27
|
...props
|
|
25
28
|
}: TypeMenuItemProps) => {
|
|
@@ -50,6 +53,7 @@ export const MenuItem = ({
|
|
|
50
53
|
// use anchor tag if href is provided and button for menuitem
|
|
51
54
|
as={isListbox ? undefined : href ? "a" : "button"}
|
|
52
55
|
href={href}
|
|
56
|
+
// eslint-disable-next-line react/no-children-prop
|
|
53
57
|
children={children}
|
|
54
58
|
{...optionProps}
|
|
55
59
|
{...props}
|
package/src/MenuItem/styles.tsx
CHANGED
|
@@ -14,6 +14,8 @@ const highlightedStyles = css`
|
|
|
14
14
|
cursor: pointer;
|
|
15
15
|
border: 1px solid ${({ theme }) => theme.colors.listItem.border.base};
|
|
16
16
|
background-color: ${({ theme }) => theme.colors.listItem.background.hover};
|
|
17
|
+
color: ${({ theme }) => theme.colors.text.body};
|
|
18
|
+
text-decoration: none;
|
|
17
19
|
`;
|
|
18
20
|
|
|
19
21
|
export const MenuItemContainer = styled.li`
|
|
@@ -11,21 +11,31 @@ Feature: Menu Root
|
|
|
11
11
|
When the esc key is pressed
|
|
12
12
|
Then the menu closes
|
|
13
13
|
|
|
14
|
-
Scenario
|
|
15
|
-
Given a
|
|
16
|
-
When the
|
|
17
|
-
Then
|
|
18
|
-
|
|
19
|
-
Examples:
|
|
20
|
-
| use case | the correct element |
|
|
21
|
-
| contains at least one MenuItem | the first MenuItem |
|
|
22
|
-
| has a custom defaultFocusRef | the defaultFocusRef element |
|
|
23
|
-
| has no MenuItems and no defaultFocusRef | an error is logged and the first focusable child |
|
|
14
|
+
Scenario: MenuRoot closes when clicking outside of it
|
|
15
|
+
Given a menu is open
|
|
16
|
+
When the user clicks outside of it
|
|
17
|
+
Then the menu closes
|
|
24
18
|
|
|
25
19
|
Rule: API
|
|
26
20
|
Background:
|
|
27
21
|
Given an engineer is implementing the component
|
|
28
22
|
|
|
23
|
+
Scenario: MenuRoot triggers the Menu to open when isOpen true
|
|
24
|
+
Given the Menu is closed
|
|
25
|
+
And MenuContext.isOpen is false
|
|
26
|
+
When MenuContext.isOpen is changed to true
|
|
27
|
+
Then the Menu will open
|
|
28
|
+
|
|
29
|
+
Scenario: MenuRoot triggers the Menu to close when isOpen false
|
|
30
|
+
Given the Menu is open
|
|
31
|
+
And MenuContext.isOpen is true
|
|
32
|
+
When MenuContext.isOpen is changed to false
|
|
33
|
+
Then the Menu will close
|
|
34
|
+
|
|
35
|
+
Scenario: MenuRoot accepts a custom toggleButtonElement
|
|
36
|
+
When MenuRoot receives a a custom element for the toggleButtonElement prop
|
|
37
|
+
Then the Menu will render the custom element
|
|
38
|
+
|
|
29
39
|
Scenario: MenuRoot is using keyboard instructions
|
|
30
40
|
Given a screen reader is being used
|
|
31
41
|
When a MenuRoot is provided with the keyboardInstructions prop
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import React, { useCallback
|
|
1
|
+
import React, { useCallback } from "react";
|
|
2
2
|
import styled from "styled-components";
|
|
3
3
|
import { Popout, type TypePopoutProps } from "@sproutsocial/seeds-react-popout";
|
|
4
4
|
|
|
5
5
|
import { MenuProvider, type TypeMenuProviderProps } from "../MenuContext";
|
|
6
6
|
|
|
7
7
|
export interface TypeMenuRootOwnProps {
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
9
|
menuToggleElement: React.ReactElement<any>;
|
|
9
10
|
popoutProps?: Partial<
|
|
10
11
|
Omit<TypePopoutProps, "isOpen" | "setIsOpen" | "content" | "children">
|
|
@@ -3,8 +3,8 @@ import {
|
|
|
3
3
|
fireEvent,
|
|
4
4
|
render,
|
|
5
5
|
screen,
|
|
6
|
+
waitFor,
|
|
6
7
|
} from "@sproutsocial/seeds-react-testing-library";
|
|
7
|
-
import { Box } from "@sproutsocial/seeds-react-box";
|
|
8
8
|
import { Button } from "@sproutsocial/seeds-react-button";
|
|
9
9
|
import {
|
|
10
10
|
MenuRoot,
|
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
MenuContext,
|
|
14
14
|
MenuFooter,
|
|
15
15
|
MenuHeader,
|
|
16
|
+
MenuGroup,
|
|
16
17
|
type TypeMenuContextProps,
|
|
17
18
|
} from "../../index";
|
|
18
19
|
|
|
20
|
+
// eslint-disable-next-line react/display-name
|
|
19
21
|
jest.mock("@sproutsocial/seeds-react-portal", () => ({ children }) => (
|
|
20
22
|
<div>{children}</div>
|
|
21
23
|
));
|
|
@@ -42,28 +44,62 @@ const TestMenuFooter = () => {
|
|
|
42
44
|
// };
|
|
43
45
|
|
|
44
46
|
describe("MenuRoot", () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
it("closes when esc is pressed", async () => {
|
|
48
|
+
const mockCloseMenu = jest.fn();
|
|
49
|
+
const { user } = render(
|
|
50
|
+
<MenuRoot
|
|
51
|
+
menuToggleElement={
|
|
52
|
+
<MenuToggleButton role="button" aria-label="Open Menu">
|
|
53
|
+
Open
|
|
54
|
+
</MenuToggleButton>
|
|
55
|
+
}
|
|
56
|
+
closeMenu={mockCloseMenu}
|
|
57
|
+
isOpen
|
|
58
|
+
>
|
|
59
|
+
Menu
|
|
60
|
+
</MenuRoot>
|
|
61
|
+
);
|
|
49
62
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
// Check that Menu is open
|
|
64
|
+
const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
|
|
65
|
+
expect(menuTrigger).toBeInTheDocument();
|
|
66
|
+
expect(await screen.findByText("Menu")).toBeInTheDocument();
|
|
67
|
+
|
|
68
|
+
// Press esc key
|
|
69
|
+
await user.keyboard("{Esc}");
|
|
70
|
+
|
|
71
|
+
// Check that menu closes
|
|
72
|
+
await waitFor(() => expect(mockCloseMenu).toHaveBeenCalled());
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should close when clicking outside of the menu", async () => {
|
|
76
|
+
const mockCloseMenu = jest.fn();
|
|
77
|
+
const { user } = render(
|
|
78
|
+
<div>
|
|
79
|
+
<MenuRoot
|
|
80
|
+
menuToggleElement={
|
|
81
|
+
<MenuToggleButton role="button" aria-label="Open Menu">
|
|
82
|
+
Open
|
|
83
|
+
</MenuToggleButton>
|
|
84
|
+
}
|
|
85
|
+
closeMenu={mockCloseMenu}
|
|
86
|
+
isOpen
|
|
87
|
+
>
|
|
88
|
+
Menu
|
|
89
|
+
</MenuRoot>
|
|
90
|
+
<button>Outside</button>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(await screen.findByText("Menu")).toBeInTheDocument();
|
|
95
|
+
|
|
96
|
+
// Click outside the menu
|
|
97
|
+
const otherButton = screen.getByRole("button", { name: "Outside" });
|
|
98
|
+
await user.click(otherButton);
|
|
99
|
+
|
|
100
|
+
// Check that menu closes
|
|
101
|
+
await waitFor(() => expect(mockCloseMenu).toHaveBeenCalled());
|
|
102
|
+
});
|
|
67
103
|
|
|
68
104
|
// TODO: write tests for keyboard instructions when feature added
|
|
69
105
|
// it("triggers keyboard instructions", () => {
|
|
@@ -74,7 +110,108 @@ describe("MenuRoot", () => {
|
|
|
74
110
|
// // has no keyboard instructions by default
|
|
75
111
|
// });
|
|
76
112
|
|
|
77
|
-
|
|
113
|
+
it("opens when isOpen changes from false to true", async () => {
|
|
114
|
+
const TestMenu = () => {
|
|
115
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
116
|
+
const toggleMenu = () => setIsOpen(!isOpen);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<MenuRoot
|
|
120
|
+
menuToggleElement={
|
|
121
|
+
<MenuToggleButton
|
|
122
|
+
role="button"
|
|
123
|
+
aria-label="Open Menu"
|
|
124
|
+
onClick={toggleMenu}
|
|
125
|
+
>
|
|
126
|
+
Open
|
|
127
|
+
</MenuToggleButton>
|
|
128
|
+
}
|
|
129
|
+
isOpen={isOpen}
|
|
130
|
+
>
|
|
131
|
+
Menu
|
|
132
|
+
</MenuRoot>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
const { user } = render(<TestMenu />);
|
|
136
|
+
|
|
137
|
+
// Check that Menu is open
|
|
138
|
+
const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
|
|
139
|
+
expect(menuTrigger).toBeInTheDocument();
|
|
140
|
+
expect(screen.queryByText("Menu")).not.toBeInTheDocument();
|
|
141
|
+
|
|
142
|
+
// Click the menu trigger
|
|
143
|
+
await user.click(menuTrigger);
|
|
144
|
+
|
|
145
|
+
// Check that menu opens
|
|
146
|
+
expect(await screen.findByText("Menu")).toBeInTheDocument();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("closes when isOpen changes from true to false", async () => {
|
|
150
|
+
const TestMenu = () => {
|
|
151
|
+
const [isOpen, setIsOpen] = useState(true);
|
|
152
|
+
const toggleMenu = () => setIsOpen(!isOpen);
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<MenuRoot
|
|
156
|
+
menuToggleElement={
|
|
157
|
+
<MenuToggleButton
|
|
158
|
+
role="button"
|
|
159
|
+
aria-label="Open Menu"
|
|
160
|
+
onClick={toggleMenu}
|
|
161
|
+
>
|
|
162
|
+
Open
|
|
163
|
+
</MenuToggleButton>
|
|
164
|
+
}
|
|
165
|
+
isOpen={isOpen}
|
|
166
|
+
>
|
|
167
|
+
Menu
|
|
168
|
+
</MenuRoot>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
const { user } = render(<TestMenu />);
|
|
172
|
+
|
|
173
|
+
// Check that Menu is open
|
|
174
|
+
const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
|
|
175
|
+
expect(menuTrigger).toBeInTheDocument();
|
|
176
|
+
expect(await screen.findByText("Menu")).toBeInTheDocument();
|
|
177
|
+
|
|
178
|
+
// Click the menu trigger
|
|
179
|
+
await user.click(menuTrigger);
|
|
180
|
+
|
|
181
|
+
// Check that menu opens
|
|
182
|
+
await waitFor(() =>
|
|
183
|
+
expect(screen.queryByText("Menu")).not.toBeInTheDocument()
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("renders a custom toggle when passed to menuToggleElement", async () => {
|
|
188
|
+
const mockToggleClick = jest.fn();
|
|
189
|
+
const CustomToggleElement = () => {
|
|
190
|
+
const { getToggleButtonProps } = useContext(MenuContext);
|
|
191
|
+
return (
|
|
192
|
+
<div role="button" {...getToggleButtonProps?.()}>
|
|
193
|
+
Toggle
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
};
|
|
197
|
+
const { user } = render(
|
|
198
|
+
<MenuRoot
|
|
199
|
+
menuToggleElement={<CustomToggleElement />}
|
|
200
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
201
|
+
// @ts-ignore - mock contextProps may be imperfect
|
|
202
|
+
getToggleButtonProps={() => ({ onClick: mockToggleClick })}
|
|
203
|
+
>
|
|
204
|
+
Menu
|
|
205
|
+
</MenuRoot>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const menuTrigger = screen.getByRole("button", { name: "Toggle" });
|
|
209
|
+
expect(menuTrigger).toBeInTheDocument();
|
|
210
|
+
await user.click(menuTrigger);
|
|
211
|
+
|
|
212
|
+
expect(mockToggleClick).toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
78
215
|
it("renders custom children", () => {
|
|
79
216
|
render(
|
|
80
217
|
<MenuRoot
|
|
@@ -83,9 +220,9 @@ describe("MenuRoot", () => {
|
|
|
83
220
|
>
|
|
84
221
|
<MenuHeader title="Menu Header" />
|
|
85
222
|
<div>Custom Banner</div>
|
|
86
|
-
<
|
|
223
|
+
<MenuGroup title="Menu Group">
|
|
87
224
|
<MenuItem id="1">Item 1</MenuItem>
|
|
88
|
-
</
|
|
225
|
+
</MenuGroup>
|
|
89
226
|
<MenuFooter>Menu Footer</MenuFooter>
|
|
90
227
|
</MenuRoot>
|
|
91
228
|
);
|
|
@@ -94,14 +231,11 @@ describe("MenuRoot", () => {
|
|
|
94
231
|
expect(screen.getByText("Custom Banner")).toBeInTheDocument();
|
|
95
232
|
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
|
96
233
|
expect(screen.getByText("Menu Footer")).toBeInTheDocument();
|
|
234
|
+
expect(
|
|
235
|
+
screen.getByRole("group", { name: "Menu Group" })
|
|
236
|
+
).toBeInTheDocument();
|
|
97
237
|
});
|
|
98
238
|
|
|
99
|
-
// TODO: test this using the SingleSelectMenu, MultiSelectMenu, and ComboBox
|
|
100
|
-
// it("passes downshift props to the MenuContext", () => {
|
|
101
|
-
// // passes downshift props to the MenuContext
|
|
102
|
-
// // Can be tested by the components that use downshift
|
|
103
|
-
// });
|
|
104
|
-
|
|
105
239
|
it("can be implemented without using Downshift", () => {
|
|
106
240
|
const randomText = "Some random text";
|
|
107
241
|
const TestCustomMenu = () => {
|
|
@@ -40,25 +40,14 @@ Feature: MultiSelectMenu
|
|
|
40
40
|
Then the component renders the existing values
|
|
41
41
|
|
|
42
42
|
Scenario: Component handles new selection state
|
|
43
|
-
When the MultiSelectMenu is not passed
|
|
43
|
+
When the MultiSelectMenu is not passed a `selectedItems` prop
|
|
44
44
|
And a new selection is created
|
|
45
|
-
Then the '
|
|
45
|
+
Then the 'onSelectedItemsChange' callback is fired
|
|
46
46
|
|
|
47
47
|
Scenario: Menu does not close on selection
|
|
48
48
|
When an item is selected
|
|
49
49
|
Then the 'onSelectedItemsChange' callback fires without closing the menu
|
|
50
50
|
|
|
51
|
-
Scenario Outline: Actions can be triggered from the MultiSelectMenu
|
|
52
|
-
When actionable elements are provided to the MultiSelectMenu
|
|
53
|
-
And an `<action>` is clicked
|
|
54
|
-
Then the corresponding `onClick` should be executed
|
|
55
|
-
|
|
56
|
-
Examples: Action Types
|
|
57
|
-
| action |
|
|
58
|
-
| ---------------- |
|
|
59
|
-
| select button |
|
|
60
|
-
| deselect button |
|
|
61
|
-
|
|
62
51
|
Scenario Outline: MultiSelectMenu checks its types
|
|
63
52
|
When `<propName>` is passed a value
|
|
64
53
|
Then MultiSelectMenu expects it to be of type `<propType>`
|
|
@@ -82,6 +82,7 @@ export const MultiSelectMenu = ({
|
|
|
82
82
|
defaultHighlightedIndex: 0,
|
|
83
83
|
stateReducer: (state, actionAndChanges) => {
|
|
84
84
|
let newState: Partial<UseSelectState<TypeDefaultItemProps>>;
|
|
85
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
85
86
|
newState = useSelectStateReducer(state, actionAndChanges);
|
|
86
87
|
newState = selectReducerForMultiSelect(state, {
|
|
87
88
|
...actionAndChanges,
|