@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-menu",
3
- "version": "0.2.0",
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
- "@sproutsocial/eslint-config-seeds": "*",
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, RefObject } from "react";
2
+ import type { ReactNode } from "react";
4
3
 
5
4
  export interface TypeMenuContentProps extends HTMLMotionProps<"ul"> {
6
5
  children: ReactNode;
@@ -1,9 +1,4 @@
1
- import React, {
2
- createContext,
3
- useMemo,
4
- useContext,
5
- type RefObject,
6
- } from "react";
1
+ import React, { createContext, useMemo, useContext } from "react";
7
2
  import type {
8
3
  UseSelectReturnValue,
9
4
  UseSelectProps,
@@ -25,5 +25,6 @@ export interface TypeMenuGroupProps
25
25
  Omit<React.ComponentPropsWithoutRef<"ul">, "color"> {
26
26
  title?: string;
27
27
  children: ReactNode;
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
29
  titleAs?: string | React.ComponentType<any>;
29
30
  }
@@ -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}
@@ -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`
@@ -85,6 +85,7 @@ export const useOptionProps = ({
85
85
  () =>
86
86
  selectedItem?.id === id ||
87
87
  !!selectedItems?.find((item) => item?.id === id),
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
89
  [selectedItem, selectedItems]
89
90
  );
90
91
 
@@ -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 Outline: MenuRoot focuses the correct element when opened
15
- Given a MenuRoot <use case>
16
- When the the menu is opened
17
- Then <correct element> is in focus
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, useRef } from "react";
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
- // TODO: test this on the Menu components that use downshift?
46
- // it("closes when esc is pressed", () => {
47
- // // closes when esc is pressed
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
- // TODO: write these tests when focus logic is implemented
51
- // test.each([
52
- // {
53
- // element: "focuses the first MenuItem",
54
- // condition: "contains at least one MenuItem",
55
- // },
56
- // {
57
- // element: "focuses the defaultFocusRef element",
58
- // condition: "has a custom defaultFocusRef",
59
- // },
60
- // {
61
- // element: "an error is logged and focuses the first focusable child",
62
- // condition: "has no MenuItems and no defaultFocusRef",
63
- // },
64
- // ])("$element when opened if it $condition", ({}) => {
65
- // // focuses the correct element when opened
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
- // TODO: Add MenuGroup when the component is ready
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
- <Box>
223
+ <MenuGroup title="Menu Group">
87
224
  <MenuItem id="1">Item 1</MenuItem>
88
- </Box>
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 'onSelectedItemsChangee' callback is fired
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,