@sproutsocial/seeds-react-menu 0.1.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.
Files changed (62) hide show
  1. package/.eslintignore +6 -0
  2. package/.eslintrc.js +6 -2
  3. package/.turbo/turbo-build.log +10 -10
  4. package/CHANGELOG.md +23 -1
  5. package/dist/esm/index.js +243 -217
  6. package/dist/esm/index.js.map +1 -1
  7. package/dist/index.d.mts +104 -38
  8. package/dist/index.d.ts +104 -38
  9. package/dist/index.js +272 -243
  10. package/dist/index.js.map +1 -1
  11. package/package.json +10 -7
  12. package/src/ActionMenu/ActionMenu.feature +9 -3
  13. package/src/ActionMenu/ActionMenu.tsx +76 -42
  14. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +36 -42
  15. package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +13 -13
  16. package/src/MenuContent/MenuContent.tsx +31 -1
  17. package/src/MenuContent/MenuContentTypes.ts +3 -4
  18. package/src/MenuContent/styles.tsx +10 -6
  19. package/src/MenuContext.tsx +1 -6
  20. package/src/MenuFooter/MenuFooter.feature +1 -1
  21. package/src/MenuFooter/MenuFooter.tsx +8 -0
  22. package/src/MenuFooter/__tests__/MenuFooter.test.tsx +53 -26
  23. package/src/MenuFooter/styles.tsx +1 -1
  24. package/src/MenuGroup/MenuGroup.feature +14 -26
  25. package/src/MenuGroup/MenuGroup.tsx +21 -1
  26. package/src/MenuGroup/MenuGroupTypes.ts +1 -0
  27. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +41 -6
  28. package/src/MenuGroup/styles.tsx +4 -3
  29. package/src/MenuHeader/MenuHeader.tsx +16 -5
  30. package/src/MenuHeader/__tests__/MenuHeader.test.tsx +50 -6
  31. package/src/MenuHeader/__tests__/MenuHeader.typetest.tsx +1 -0
  32. package/src/MenuHeader/styles.tsx +21 -22
  33. package/src/MenuItem/MenuItem.feature +66 -26
  34. package/src/MenuItem/MenuItem.tsx +39 -20
  35. package/src/MenuItem/MenuItemTypes.ts +13 -1
  36. package/src/MenuItem/__tests__/MenuItem.test.tsx +431 -154
  37. package/src/MenuItem/__tests__/MenuItem.typetest.tsx +57 -38
  38. package/src/MenuItem/styles.tsx +44 -3
  39. package/src/{MenuItemSelectable/MenuItemSelectable.tsx → MenuItem/useOptionProps.tsx} +32 -54
  40. package/src/MenuRoot/MenuRoot.feature +20 -10
  41. package/src/MenuRoot/MenuRoot.tsx +14 -18
  42. package/src/MenuRoot/__tests__/MenuRoot.test.tsx +168 -34
  43. package/src/MenuRoot/__tests__/MenuRoot.typetest.tsx +10 -10
  44. package/src/MultiSelectMenu/MultiSelectMenu.feature +2 -13
  45. package/src/MultiSelectMenu/MultiSelectMenu.tsx +9 -1
  46. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +154 -153
  47. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +10 -10
  48. package/src/SingleSelectMenu/SingleSelectMenu.feature +11 -0
  49. package/src/SingleSelectMenu/SingleSelectMenu.tsx +10 -0
  50. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.test.tsx +11 -14
  51. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +14 -14
  52. package/src/__tests__/Menu.test.tsx +20 -16
  53. package/src/hooks/useItemsFromChildren.tsx +2 -4
  54. package/src/index.ts +0 -1
  55. package/src/menuTestingUtils.tsx +7 -4
  56. package/tsconfig.json +1 -1
  57. package/src/MenuItemSelectable/MenuItemSelectable.feature +0 -99
  58. package/src/MenuItemSelectable/MenuItemSelectableTypes.ts +0 -15
  59. package/src/MenuItemSelectable/__tests__/MenuItemSelectable.test.tsx +0 -303
  60. package/src/MenuItemSelectable/__tests__/MenuItemSelectable.typetest.tsx +0 -56
  61. package/src/MenuItemSelectable/index.ts +0 -5
  62. package/src/MenuItemSelectable/styles.tsx +0 -21
@@ -1,41 +1,60 @@
1
1
  import { MenuItem } from "../MenuItem";
2
2
 
3
- export const MenuItemTypeTest = () => (
4
- <>
5
- {/* Valid MenuItem with minimal required props */}
6
- <MenuItem id="test">Test</MenuItem>
7
- {/* Valid MenuItem allows disabled prop */}
8
- <MenuItem id="test" disabled>
9
- Test
10
- </MenuItem>
11
- {/* Valid MenuItem allows disabled prop false */}
12
- <MenuItem id="test" disabled={false}>
13
- Test
14
- </MenuItem>
3
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
4
+ function MenuItemTypeTest() {
5
+ return (
6
+ <>
7
+ {/* Valid MenuItem with minimal required props */}
8
+ <MenuItem id="test">Test</MenuItem>
9
+ {/* Valid MenuItem allows href */}
10
+ <MenuItem id="test" href="test.com">
11
+ Test
12
+ </MenuItem>
13
+ {/* Valid MenuItem allows active prop */}
14
+ <MenuItem id="test" active>
15
+ Test
16
+ </MenuItem>
17
+ {/* Valid MenuItem allows active prop false */}
18
+ <MenuItem id="test" active={false}>
19
+ Test
20
+ </MenuItem>
21
+ {/* Valid MenuItem allows inputType prop to be checkbox */}
22
+ <MenuItem id="test" inputType="checkbox">
23
+ Test
24
+ </MenuItem>
25
+ {/* Valid MenuItem allows inputType prop to be radio */}
26
+ <MenuItem id="test" inputType="radio">
27
+ Test
28
+ </MenuItem>
29
+ {/* Valid MenuItem allows inputType prop to be switch */}
30
+ <MenuItem id="test" inputType="switch">
31
+ Test
32
+ </MenuItem>
33
+ {/* Valid MenuItem allows inputType prop to be icon */}
34
+ <MenuItem id="test" inputType="icon">
35
+ Test
36
+ </MenuItem>
37
+ {/* Valid MenuItem allows inputLabelId prop as a string */}
38
+ <MenuItem id="test" inputLabelId="Test Label">
39
+ Test
40
+ </MenuItem>
15
41
 
16
- {/* @ts-expect-error - no children is invalid */}
17
- <MenuItem id="foo" />
18
- {/* @ts-expect-error - test empty states are invalid */}
19
- <MenuItem />
20
- {/* @ts-expect-error - test missing index is invalid */}
21
- <MenuItem id="foo" />
22
- {/* @ts-expect-error - test missing id is invalid */}
23
- <MenuItem />
24
- {/* @ts-expect-error - test that types are invalid */}
25
- <MenuItem id={0} />
26
- {/* @ts-expect-error -test that wrong highlighted type fails */}
27
- <MenuItem $highlighted="string">Hello world!</MenuItem>
28
- {/* @ts-expect-error -test that wrong role type fails */}
29
- <MenuItem role="wrong">Hello world!</MenuItem>
30
- {/* @ts-expect-error - missing id is invalid */}
31
- <MenuItem>Test</MenuItem>
32
- <MenuItem
33
- /* @ts-expect-error - id may not be a number */
34
- id={0}
35
- /* @ts-expect-error - disabled may not be a string */
36
- disabled="true"
37
- >
38
- Test
39
- </MenuItem>
40
- </>
41
- );
42
+ {/* @ts-expect-error - no children is invalid */}
43
+ <MenuItem id="foo" />
44
+ {/* @ts-expect-error - missing id is invalid */}
45
+ <MenuItem>Test</MenuItem>
46
+ <MenuItem
47
+ /* @ts-expect-error - id may not be a number */
48
+ id={0}
49
+ /* @ts-expect-error - active may not be a string */
50
+ active="true"
51
+ /* @ts-expect-error - inputType must be valid */
52
+ inputType="invalid"
53
+ /* @ts-expect-error - inputLabelId may not be a number */
54
+ inputLabelId={0}
55
+ >
56
+ Test
57
+ </MenuItem>
58
+ </>
59
+ );
60
+ }
@@ -14,20 +14,41 @@ 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;
19
+ `;
20
+
21
+ export const MenuItemContainer = styled.li`
22
+ box-sizing: border-box;
23
+ list-style-type: none;
24
+ margin-left: ${({ theme }) => theme.space[300]};
25
+ margin-right: ${({ theme }) => theme.space[300]};
26
+ transition: all ${({ theme }) => theme.duration["fast"]};
27
+
28
+ &:first-child {
29
+ margin-top: ${({ theme }) => theme.space[300]};
30
+ }
31
+
32
+ &:last-child {
33
+ margin-bottom: ${({ theme }) => theme.space[300]};
34
+ }
17
35
  `;
18
36
 
19
- export const MenuItemContainer = styled.li<{ $highlighted: boolean }>`
37
+ export const StyledMenuItem = styled.div<{ $highlighted: boolean }>`
38
+ display: block;
39
+ box-sizing: border-box;
40
+ width: 100%;
20
41
  border-radius: ${({ theme }) => theme.radii[500]};
21
42
  background-color: ${({ theme }) => theme.colors.listItem.background.base};
22
43
  border: 1px solid ${({ theme }) => theme.colors.listItem.background.base};
23
44
  color: ${({ theme }) => theme.colors.text.body};
24
45
  font-family: ${({ theme }) => theme.fontFamily};
25
46
  font-weight: ${({ theme }) => theme.fontWeights.normal};
47
+ text-align: left;
48
+ text-decoration: none;
26
49
  padding: ${({ theme }) => theme.space[300]};
27
- list-style-type: none;
28
50
  outline: 0;
29
51
  ${({ theme }) => theme.typography[200]};
30
- transition: all ${({ theme }) => theme.duration["fast"]};
31
52
 
32
53
  /* Highlighted */
33
54
  ${({ $highlighted }) => $highlighted && highlightedStyles}
@@ -69,3 +90,23 @@ export const MenuItemContainer = styled.li<{ $highlighted: boolean }>`
69
90
  ${layout}
70
91
  ${space}
71
92
  `;
93
+
94
+ export const MenuItemRow = styled.div`
95
+ display: flex;
96
+ align-items: center;
97
+ pointer-events: none;
98
+ `;
99
+
100
+ export const MenuItemAction = styled.div`
101
+ margin-right: ${({ theme }) => theme.space[300]};
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ flex-shrink: 0;
106
+ `;
107
+
108
+ export const MenuItemText = styled.div`
109
+ word-break: break-word;
110
+ flex-grow: 1;
111
+ min-width: 0;
112
+ `;
@@ -6,18 +6,9 @@ import { Radio } from "@sproutsocial/seeds-react-radio";
6
6
  import { useTheme } from "styled-components";
7
7
  import { Box } from "@sproutsocial/seeds-react-box";
8
8
  import { MenuContext } from "../MenuContext";
9
- import { MenuItem } from "../MenuItem";
10
9
 
11
- // import { type TypeMenuItemRoles } from "../constants";
12
- import {
13
- MenuItemSelectableAction,
14
- MenuItemSelectableRow,
15
- MenuItemSelectableText,
16
- } from "./styles";
17
- import type {
18
- TypeMenuItemSelectableProps,
19
- InputType,
20
- } from "./MenuItemSelectableTypes";
10
+ import type { TypeMenuItemProps, InputType } from "./MenuItemTypes";
11
+ import { MenuItemAction, MenuItemRow, MenuItemText } from "./styles";
21
12
 
22
13
  /**
23
14
  * SelectionIndicator - handles logic for appropriate rendering of inputs
@@ -47,7 +38,7 @@ export const SelectionIndicator = ({
47
38
  onChange={() => {}}
48
39
  checked={selected}
49
40
  aria-labelledby={labeledBy}
50
- id={`MenuItemSelectable-${inputType}-${id}`}
41
+ id={`MenuItem-${inputType}-${id}`}
51
42
  />
52
43
  );
53
44
  case "switch":
@@ -56,15 +47,15 @@ export const SelectionIndicator = ({
56
47
  onClick={inputStub}
57
48
  checked={!!selected}
58
49
  aria-labelledby={labeledBy}
59
- id={`MenuItemSelectable-${inputType}-${id}`}
50
+ id={`MenuItem-${inputType}-${id}`}
60
51
  />
61
52
  );
62
53
  case "radio":
63
54
  return (
64
55
  <Radio
65
56
  checked={selected}
66
- id={`MenuItemSelectable-${inputType}-${id}`}
67
- name={`MenuItemSelectable-${inputType}`}
57
+ id={`MenuItem-${inputType}-${id}`}
58
+ name={`MenuItem-${inputType}`}
68
59
  aria-labelledby={labeledBy}
69
60
  />
70
61
  );
@@ -80,57 +71,44 @@ export const SelectionIndicator = ({
80
71
  };
81
72
 
82
73
  /**
83
- * MenuItemSelectable - primitive with controlled input states
74
+ * useOptionProps - props needed for the option menu items
84
75
  */
85
- export const MenuItemSelectable = ({
76
+ export const useOptionProps = ({
86
77
  id,
87
78
  children,
88
79
  inputType = "icon",
89
- inputLabel,
80
+ inputLabelId,
90
81
  ...props
91
- }: TypeMenuItemSelectableProps) => {
82
+ }: TypeMenuItemProps) => {
92
83
  const { selectedItem, selectedItems, isListbox } = useContext(MenuContext);
93
84
  const selected = useMemo(
94
85
  () =>
95
86
  selectedItem?.id === id ||
96
87
  !!selectedItems?.find((item) => item?.id === id),
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
89
  [selectedItem, selectedItems]
98
90
  );
99
91
 
100
- if (isListbox === false) {
101
- console.warn(
102
- "MenuItemSelectable is invalid when isListbox is false (Menu)"
103
- );
104
- }
105
-
106
- // const roles: Record<InputType, TypeMenuItemRoles> = {
107
- // checkbox: "menuitemcheckbox",
108
- // switch: "menuitemcheckbox",
109
- // radio: "menuitemradio",
110
- // icon: "option",
111
- // };
112
-
113
- const label = inputLabel || `menu-item-child-${id}`;
92
+ const label = inputLabelId || `menu-item-child-${id}`;
114
93
 
115
- return (
116
- <MenuItem
117
- active={selected}
118
- id={id}
119
- // role={roles["icon"]}
120
- aria-selected={selected}
121
- {...props}
122
- >
123
- <MenuItemSelectableRow>
124
- <MenuItemSelectableAction>
125
- <SelectionIndicator
126
- id={id}
127
- aria-labelledby={label}
128
- inputType={inputType}
129
- selected={!!selected}
130
- />
131
- </MenuItemSelectableAction>
132
- <MenuItemSelectableText id={label}>{children}</MenuItemSelectableText>
133
- </MenuItemSelectableRow>
134
- </MenuItem>
135
- );
94
+ return isListbox
95
+ ? {
96
+ active: selected,
97
+ ["aria-selected"]: selected,
98
+ children: (
99
+ <MenuItemRow>
100
+ <MenuItemAction>
101
+ <SelectionIndicator
102
+ id={id}
103
+ aria-labelledby={label}
104
+ inputType={inputType}
105
+ selected={!!selected}
106
+ />
107
+ </MenuItemAction>
108
+ <MenuItemText id={label}>{children}</MenuItemText>
109
+ </MenuItemRow>
110
+ ),
111
+ ...props,
112
+ }
113
+ : {};
136
114
  };
@@ -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,11 +1,12 @@
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
- triggerElement: React.ReactElement<any>;
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ menuToggleElement: React.ReactElement<any>;
9
10
  popoutProps?: Partial<
10
11
  Omit<TypePopoutProps, "isOpen" | "setIsOpen" | "content" | "children">
11
12
  >;
@@ -20,14 +21,21 @@ const CustomPopoutContent = styled(Popout.Content)`
20
21
  margin-left: 0;
21
22
  margin-right: 0;
22
23
  `;
24
+
25
+ /**
26
+ * @link https://seeds.sproutsocial.com/components/menu-root/
27
+ *
28
+ * @description MenuRoot is a utility component used to handle {@link https://github.com/sproutsocial/seeds/blob/dev/seeds-react/seeds-react-menu/src/MenuContext.tsx | MenuContext}. This component should rarely, if ever, be used directly unless building a custom menu type.
29
+ */
30
+
23
31
  export const MenuRoot = ({
24
32
  children,
25
- triggerElement,
33
+ menuToggleElement,
26
34
  popoutProps,
27
35
  width = "300px",
28
36
  ...contextProps
29
37
  }: TypeMenuRootProps) => {
30
- const { isListbox, isOpen, openMenu, closeMenu, getMenuProps } = contextProps;
38
+ const { isOpen, openMenu, closeMenu } = contextProps;
31
39
  const setIsOpen = useCallback(
32
40
  (newIsOpen: boolean) => {
33
41
  newIsOpen ? openMenu?.() : closeMenu?.();
@@ -41,19 +49,7 @@ export const MenuRoot = ({
41
49
  isOpen={!!isOpen}
42
50
  setIsOpen={setIsOpen}
43
51
  content={
44
- <CustomPopoutContent width={width}>
45
- <div
46
- {...getMenuProps?.(
47
- {
48
- ...(isListbox ? {} : { role: "menu" }),
49
- },
50
- // Suppress ref error for now
51
- { suppressRefError: true }
52
- )}
53
- >
54
- {children}
55
- </div>
56
- </CustomPopoutContent>
52
+ <CustomPopoutContent width={width}>{children}</CustomPopoutContent>
57
53
  }
58
54
  focusLockProps={{
59
55
  // correct jerking motion when scrolling while the Popout is open
@@ -63,7 +59,7 @@ export const MenuRoot = ({
63
59
  disableWrapperAria
64
60
  {...popoutProps}
65
61
  >
66
- {triggerElement}
62
+ {menuToggleElement}
67
63
  </Popout>
68
64
  </MenuProvider>
69
65
  );
@@ -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,18 +110,119 @@ 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
81
- triggerElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
218
+ menuToggleElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
82
219
  isOpen
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 = () => {
@@ -120,7 +254,7 @@ describe("MenuRoot", () => {
120
254
 
121
255
  return (
122
256
  <MenuRoot
123
- triggerElement={
257
+ menuToggleElement={
124
258
  <MenuToggleButton appearance="secondary">
125
259
  Open Menu (<TestComponent />)
126
260
  </MenuToggleButton>
@@ -181,7 +315,7 @@ describe("MenuRoot", () => {
181
315
  ({ TestComponent, contextProps, testBtnText, testFn }) => {
182
316
  render(
183
317
  <MenuRoot
184
- triggerElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
318
+ menuToggleElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
185
319
  isOpen
186
320
  {...contextProps}
187
321
  >