@sproutsocial/seeds-react-menu 0.5.0 → 1.0.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 (104) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +72 -0
  3. package/dist/esm/index.js +1529 -440
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/index.d.mts +275 -92
  6. package/dist/index.d.ts +275 -92
  7. package/dist/index.js +1558 -444
  8. package/dist/index.js.map +1 -1
  9. package/package.json +15 -12
  10. package/src/ActionMenu/ActionMenu.stories.tsx +422 -0
  11. package/src/ActionMenu/ActionMenu.tsx +24 -27
  12. package/src/ActionMenu/ActionMenuTypes.ts +8 -7
  13. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +17 -8
  14. package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +8 -8
  15. package/src/Autocomplete/Autocomplete.stories.tsx +38 -3
  16. package/src/Autocomplete/Autocomplete.tsx +15 -80
  17. package/src/Autocomplete/AutocompleteInput.tsx +18 -27
  18. package/src/Autocomplete/AutocompleteTokenInput.tsx +50 -0
  19. package/src/Autocomplete/AutocompleteTypes.ts +25 -12
  20. package/src/Autocomplete/MultiAutocomplete.tsx +188 -0
  21. package/src/Autocomplete/SingleAutocomplete.tsx +74 -0
  22. package/src/Autocomplete/__tests__/Autocomplete.test.tsx +207 -3
  23. package/src/Autocomplete/__tests__/Autocomplete.typetest.tsx +90 -8
  24. package/src/MenuContent/MenuContent.stories.tsx +164 -0
  25. package/src/MenuContent/MenuContent.tsx +28 -12
  26. package/src/MenuContent/MenuContentTypes.ts +6 -4
  27. package/src/MenuContent/__tests__/MenuContent.typetest.tsx +8 -8
  28. package/src/MenuContext.tsx +192 -37
  29. package/src/MenuFooter/MenuFooter.stories.tsx +202 -0
  30. package/src/MenuFooter/__tests__/MenuFooter.test.tsx +16 -11
  31. package/src/MenuFooter/styles.tsx +1 -1
  32. package/src/MenuGroup/MenuGroup.feature +16 -16
  33. package/src/MenuGroup/MenuGroup.stories.tsx +248 -0
  34. package/src/MenuGroup/MenuGroup.tsx +12 -7
  35. package/src/MenuGroup/MenuGroupTypes.ts +6 -2
  36. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +6 -6
  37. package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +7 -7
  38. package/src/MenuGroup/styles.tsx +4 -2
  39. package/src/MenuHeader/MenuHeader.stories.tsx +276 -0
  40. package/src/MenuHeader/MenuHeader.tsx +12 -7
  41. package/src/MenuHeader/__tests__/MenuHeader.test.tsx +23 -13
  42. package/src/MenuHeader/styles.tsx +22 -14
  43. package/src/MenuItem/MenuItem.stories.tsx +507 -0
  44. package/src/MenuItem/MenuItem.tsx +23 -9
  45. package/src/MenuItem/MenuItemTypes.ts +9 -4
  46. package/src/MenuItem/__tests__/MenuItem.test.tsx +6 -6
  47. package/src/MenuItem/styles.tsx +23 -5
  48. package/src/MenuItem/useOptionProps.tsx +11 -9
  49. package/src/MenuLabel.tsx +2 -2
  50. package/src/MenuRoot/MenuRoot.tsx +52 -24
  51. package/src/MenuRoot/__tests__/MenuRoot.test.tsx +59 -32
  52. package/src/MenuSearchInput/MenuSearchInput.tsx +85 -0
  53. package/src/MenuSearchInput/__tests__/MenuSearchInput.test.tsx +157 -0
  54. package/src/MenuSearchInput/__tests__/MenuSearchInput.typetest.tsx +31 -0
  55. package/src/MenuSearchInput/index.ts +1 -0
  56. package/src/MenuSearchTokenInput/MenuSearchTokenInput.tsx +108 -0
  57. package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.test.tsx +247 -0
  58. package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.typetest.tsx +33 -0
  59. package/src/MenuSearchTokenInput/index.ts +1 -0
  60. package/src/MenuToggleButton/MenuToggleButton.stories.tsx +72 -0
  61. package/src/{MenuToggleButton.tsx → MenuToggleButton/MenuToggleButton.tsx} +6 -5
  62. package/src/MenuToggleButton/index.ts +1 -0
  63. package/src/MenuTokenInput.tsx +104 -0
  64. package/src/MultiSelectMenu/MultiSelectMenu.stories.tsx +222 -0
  65. package/src/MultiSelectMenu/MultiSelectMenu.tsx +38 -68
  66. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +8 -6
  67. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +46 -17
  68. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +6 -6
  69. package/src/NestedMenu/NestedMenu.feature +109 -0
  70. package/src/NestedMenu/NestedMenu.stories.tsx +157 -0
  71. package/src/NestedMenu/NestedMenu.tsx +102 -0
  72. package/src/NestedMenu/NestedMenuContent.tsx +75 -0
  73. package/src/NestedMenu/NestedMenuContext.tsx +56 -0
  74. package/src/NestedMenu/NestedMenuHeader.tsx +25 -0
  75. package/src/NestedMenu/NestedMenuItem.tsx +41 -0
  76. package/src/NestedMenu/NestedMenuTypes.ts +47 -0
  77. package/src/NestedMenu/__tests__/NestedMenu.test.tsx +31 -0
  78. package/src/NestedMenu/__tests__/NestedMenu.typetest.tsx +134 -0
  79. package/src/NestedMenu/index.ts +5 -0
  80. package/src/SingleSelectMenu/SingleSelectMenu.stories.tsx +233 -0
  81. package/src/SingleSelectMenu/SingleSelectMenu.tsx +23 -26
  82. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +7 -5
  83. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +6 -6
  84. package/src/SubmenuItem/SubmenuItem.feature +82 -0
  85. package/src/SubmenuItem/SubmenuItem.stories.tsx +163 -0
  86. package/src/SubmenuItem/SubmenuItem.tsx +258 -0
  87. package/src/SubmenuItem/SubmenuItemTypes.ts +24 -0
  88. package/src/SubmenuItem/__tests__/SubmenuItem.test.tsx +3 -0
  89. package/src/SubmenuItem/__tests__/SubmenuItem.typetest.tsx +112 -0
  90. package/src/SubmenuItem/index.ts +2 -0
  91. package/src/hooks/useDownshiftDefaults.tsx +117 -0
  92. package/src/hooks/useItemFiltering.tsx +68 -0
  93. package/src/hooks/useMenuChildren.tsx +205 -131
  94. package/src/index.ts +6 -0
  95. package/src/menuTestingUtils.tsx +21 -10
  96. package/src/reducers/nestedMenuStateReducer.tsx +23 -0
  97. package/src/reducers/useComboboxStateReducer.tsx +26 -8
  98. package/src/reducers/useSelectStateReducer.tsx +24 -6
  99. package/src/storybookUtilities/menu-storybook-components.tsx +5 -3
  100. package/src/types.ts +38 -30
  101. package/src/utils/downshiftDefaults.ts +9 -0
  102. package/src/utils/getMultiSelectOverrides.ts +21 -0
  103. package/src/utils/reduceReducers.ts +19 -0
  104. package/src/utils/itemToString.ts +0 -5
@@ -1,5 +1,5 @@
1
- import { useMenuContext } from "../MenuContext";
2
- import type { TypeDefaultItemProps } from "../types";
1
+ import { useMenuContentContext } from "../MenuContext";
2
+ import type { TypeInternalItemProps } from "../types";
3
3
  import type { TypeMenuItemProps } from "./MenuItemTypes";
4
4
  import { MenuItemContainer, StyledMenuItem } from "./styles";
5
5
  import { useOptionProps } from "./useOptionProps";
@@ -11,51 +11,65 @@ import { useOptionProps } from "./useOptionProps";
11
11
  // radio: "menuitemradio",
12
12
  // } as const;
13
13
 
14
- export const MenuItem = ({
14
+ const MenuItem = ({
15
15
  id,
16
16
  children,
17
17
  onClick,
18
18
  onKeyDown,
19
19
  disabled = false,
20
- ref,
20
+ active,
21
+ innerRef,
21
22
  href,
22
23
  color,
23
24
  ...props
24
25
  }: TypeMenuItemProps) => {
25
- const { getItemProps, itemsMap, highlightedIndex, isListbox } =
26
- useMenuContext();
26
+ const {
27
+ getItemProps,
28
+ itemsMap,
29
+ highlightedIndex,
30
+ isListbox,
31
+ hiddenItemsMap = {},
32
+ } = useMenuContentContext();
27
33
  const { ...optionProps } = useOptionProps({
28
34
  id,
29
35
  children,
30
36
  ...props,
31
37
  });
32
- const item = (itemsMap[id] || { index: 0, id }) as TypeDefaultItemProps;
38
+ const item = (itemsMap[id] || { index: 0, id }) as TypeInternalItemProps;
33
39
 
34
40
  const highlighted = highlightedIndex === item?.index;
35
41
 
36
- return item.hidden ? (
42
+ return hiddenItemsMap[item.id] ? (
37
43
  <></>
38
44
  ) : (
39
45
  <MenuItemContainer role="none">
40
46
  <StyledMenuItem
41
47
  id={id}
42
48
  $highlighted={highlighted}
49
+ $active={active}
43
50
  {...getItemProps?.({
44
51
  ...(isListbox ? {} : { role: "menuitem" }),
45
52
  item,
46
53
  index: item.index,
47
- // This prop no longer supported in downshift
54
+ // The disabled prop no longer supported in downshift. Use isItemDisabled instead.
48
55
  // disabled,
49
56
  onClick,
50
57
  onKeyDown,
58
+ ref: innerRef,
51
59
  })}
52
60
  // use anchor tag if href is provided and button for menuitem
53
61
  as={isListbox ? undefined : href ? "a" : "button"}
54
62
  href={href}
55
63
  children={children}
64
+ // Keyboard nav uses the arrow keys, not tab.
65
+ tabIndex={-1}
56
66
  {...optionProps}
57
67
  {...props}
58
68
  />
59
69
  </MenuItemContainer>
60
70
  );
61
71
  };
72
+
73
+ MenuItem.__MENU_PRIMITIVE_TYPE = "MenuItem";
74
+
75
+ export { MenuItem };
@@ -1,6 +1,6 @@
1
1
  import type { GetItemPropsOptions } from "downshift";
2
2
  import type { TypeMenuItemRoles } from "../constants";
3
- import type { TypeDefaultItemProps } from "../types";
3
+ import type { TypeInternalItemProps } from "../types";
4
4
  import type {
5
5
  TypeBorderSystemProps,
6
6
  TypeColorSystemProps,
@@ -33,13 +33,18 @@ export type InputType = (typeof INPUT_TYPES)[keyof typeof INPUT_TYPES];
33
33
  export interface TypeMenuItemProps
34
34
  extends TypeMenuItemStyledProps,
35
35
  Omit<
36
- GetItemPropsOptions<TypeDefaultItemProps>,
37
- "as" | "item" | keyof TypeMenuItemStyledProps | keyof TypeDefaultItemProps
36
+ GetItemPropsOptions<TypeInternalItemProps>,
37
+ | "as"
38
+ | "item"
39
+ | "ref"
40
+ | keyof TypeMenuItemStyledProps
41
+ | keyof TypeInternalItemProps
38
42
  >,
39
- Omit<TypeDefaultItemProps, "index"> {
43
+ Omit<TypeInternalItemProps, "index"> {
40
44
  children: React.ReactNode;
41
45
  disabled?: boolean;
42
46
  role?: TypeMenuItemRoles;
47
+ innerRef?: React.Ref<any>;
43
48
  $highlighted?: boolean;
44
49
 
45
50
  // props for options
@@ -88,19 +88,19 @@ const INTERACTION_TYPES = {
88
88
  enter: {
89
89
  action: "pressing the Enter key",
90
90
  activate: async (user: UserEvent) => {
91
- await user.keyboard("{arrowdown}{enter}");
91
+ await act(() => user.keyboard("{arrowdown}{enter}"));
92
92
  },
93
93
  deactivate: async (user: UserEvent) => {
94
- await user.keyboard("{enter}");
94
+ await act(() => user.keyboard("{enter}"));
95
95
  },
96
96
  },
97
97
  space: {
98
98
  action: "pressing the Space key",
99
99
  activate: async (user: UserEvent) => {
100
- await user.keyboard("{arrowdown} ");
100
+ await act(() => user.keyboard("{arrowdown} "));
101
101
  },
102
102
  deactivate: async (user: UserEvent) => {
103
- await user.keyboard(" ");
103
+ await act(() => user.keyboard(" "));
104
104
  },
105
105
  },
106
106
  };
@@ -158,7 +158,7 @@ describe("MenuItem when not isListbox", () => {
158
158
  await user.tab();
159
159
  expect(targetElem).toHaveFocus();
160
160
 
161
- await user.keyboard("{enter}");
161
+ await act(() => user.keyboard("{enter}"));
162
162
 
163
163
  await waitFor(() =>
164
164
  expect(handleStateChange).toHaveBeenCalledWith({
@@ -194,7 +194,7 @@ describe("MenuItem when not isListbox", () => {
194
194
  await user.tab();
195
195
  expect(targetElem).toHaveFocus();
196
196
 
197
- await user.keyboard(" ");
197
+ await act(() => user.keyboard(" "));
198
198
 
199
199
  await waitFor(() =>
200
200
  expect(handleStateChange).toHaveBeenCalledWith({
@@ -18,6 +18,13 @@ const highlightedStyles = css`
18
18
  text-decoration: none;
19
19
  `;
20
20
 
21
+ const activeStyles = css`
22
+ border: 1px solid ${({ theme }) => theme.colors.listItem.border.base};
23
+ background-color: ${({ theme }) => theme.colors.menuItem.background.active};
24
+ color: ${({ theme }) => theme.colors.text.body};
25
+ text-decoration: none;
26
+ `;
27
+
21
28
  export const MenuItemContainer = styled.li`
22
29
  box-sizing: border-box;
23
30
  list-style-type: none;
@@ -33,7 +40,10 @@ export const MenuItemContainer = styled.li`
33
40
  }
34
41
  `;
35
42
 
36
- export const StyledMenuItem = styled.div<{ $highlighted: boolean }>`
43
+ export const StyledMenuItem = styled.button<{
44
+ $highlighted: boolean;
45
+ $active?: boolean;
46
+ }>`
37
47
  display: block;
38
48
  box-sizing: border-box;
39
49
  width: 100%;
@@ -72,10 +82,12 @@ export const StyledMenuItem = styled.div<{ $highlighted: boolean }>`
72
82
  ${focusRing}
73
83
  }
74
84
 
85
+ /* Active */
86
+ ${({ $active }) => $active && activeStyles}
87
+
75
88
  /* Pressed state*/
76
89
  &:active {
77
- background-color: ${({ theme }) => theme.colors.listItem.background.active};
78
- color: ${({ theme }) => theme.colors.text.inverse};
90
+ ${activeStyles}
79
91
  }
80
92
 
81
93
  &[aria-disabled="true"] {
@@ -96,15 +108,21 @@ export const MenuItemRow = styled.div`
96
108
  align-items: center;
97
109
  pointer-events: none;
98
110
  `;
99
-
100
111
  export const MenuItemAction = styled.div`
101
- margin-right: ${({ theme }) => theme.space[300]};
102
112
  display: flex;
103
113
  align-items: center;
104
114
  justify-content: center;
105
115
  flex-shrink: 0;
106
116
  `;
107
117
 
118
+ export const MenuItemActionLeft = styled(MenuItemAction)`
119
+ margin-right: ${({ theme }) => theme.space[300]};
120
+ `;
121
+
122
+ export const MenuItemActionRight = styled(MenuItemAction)`
123
+ margin-left: ${({ theme }) => theme.space[300]};
124
+ `;
125
+
108
126
  export const MenuItemText = styled.div`
109
127
  word-break: break-word;
110
128
  flex-grow: 1;
@@ -5,10 +5,10 @@ import { Switch } from "@sproutsocial/seeds-react-switch";
5
5
  import { Radio } from "@sproutsocial/seeds-react-radio";
6
6
  import { useTheme } from "styled-components";
7
7
  import { Box } from "@sproutsocial/seeds-react-box";
8
- import { MenuContext } from "../MenuContext";
8
+ import { useMenuContentContext } from "../MenuContext";
9
9
 
10
10
  import type { TypeMenuItemProps, InputType } from "./MenuItemTypes";
11
- import { MenuItemAction, MenuItemRow, MenuItemText } from "./styles";
11
+ import { MenuItemActionLeft, MenuItemRow, MenuItemText } from "./styles";
12
12
 
13
13
  /**
14
14
  * SelectionIndicator - handles logic for appropriate rendering of inputs
@@ -35,6 +35,7 @@ export const SelectionIndicator = ({
35
35
  case "checkbox":
36
36
  return (
37
37
  <Checkbox
38
+ tabIndex={-1}
38
39
  onChange={() => {}}
39
40
  checked={selected}
40
41
  aria-labelledby={labeledBy}
@@ -44,6 +45,7 @@ export const SelectionIndicator = ({
44
45
  case "switch":
45
46
  return (
46
47
  <Switch
48
+ tabIndex={-1}
47
49
  onClick={inputStub}
48
50
  checked={!!selected}
49
51
  aria-labelledby={labeledBy}
@@ -53,6 +55,7 @@ export const SelectionIndicator = ({
53
55
  case "radio":
54
56
  return (
55
57
  <Radio
58
+ tabIndex={-1}
56
59
  checked={selected}
57
60
  id={`MenuItem-${inputType}-${id}`}
58
61
  name={`MenuItem-${inputType}`}
@@ -80,12 +83,11 @@ export const useOptionProps = ({
80
83
  inputLabelId,
81
84
  ...props
82
85
  }: TypeMenuItemProps) => {
83
- const { selectedItem, selectedItems, isListbox } = useContext(MenuContext);
86
+ const { selectedItem, selectedItemIds, isListbox, isItemSelected } =
87
+ useMenuContentContext();
84
88
  const selected = useMemo(
85
- () =>
86
- selectedItem?.id === id ||
87
- !!selectedItems?.find((item) => item?.id === id),
88
- [selectedItem, selectedItems]
89
+ () => isItemSelected(id),
90
+ [selectedItem?.id, selectedItemIds]
89
91
  );
90
92
 
91
93
  const label = inputLabelId || `menu-item-child-${id}`;
@@ -96,14 +98,14 @@ export const useOptionProps = ({
96
98
  ["aria-selected"]: selected,
97
99
  children: (
98
100
  <MenuItemRow>
99
- <MenuItemAction>
101
+ <MenuItemActionLeft>
100
102
  <SelectionIndicator
101
103
  id={id}
102
104
  aria-labelledby={label}
103
105
  inputType={inputType}
104
106
  selected={!!selected}
105
107
  />
106
- </MenuItemAction>
108
+ </MenuItemActionLeft>
107
109
  <MenuItemText id={label}>{children}</MenuItemText>
108
110
  </MenuItemRow>
109
111
  ),
package/src/MenuLabel.tsx CHANGED
@@ -1,9 +1,9 @@
1
1
  import React from "react";
2
2
  import { Label, type TypeLabelProps } from "@sproutsocial/seeds-react-label";
3
- import { useMenuContext } from "./MenuContext";
3
+ import { useMenuToggleContext } from "./MenuContext";
4
4
 
5
5
  export const MenuLabel = ({ children, ...rest }: Partial<TypeLabelProps>) => {
6
- const { getLabelProps } = useMenuContext();
6
+ const { getLabelProps } = useMenuToggleContext();
7
7
  return (
8
8
  <Label htmlFor="fallback_menu_label" {...getLabelProps?.()} {...rest}>
9
9
  {children}
@@ -1,8 +1,14 @@
1
- import React, { useCallback, useRef } from "react";
1
+ import React, { useCallback, useEffect } from "react";
2
2
  import styled from "styled-components";
3
3
  import { Popout, type TypePopoutProps } from "@sproutsocial/seeds-react-popout";
4
4
 
5
- import { MenuProvider, type TypeMenuProviderProps } from "../MenuContext";
5
+ import {
6
+ MenuToggleProvider,
7
+ MenuContentProvider,
8
+ useMenu,
9
+ type TypeMenuContextProps,
10
+ type TypeMenuProviderProps,
11
+ } from "../MenuContext";
6
12
 
7
13
  export interface TypeMenuRootOwnProps {
8
14
  menuToggleElement: React.ReactElement<any>;
@@ -12,7 +18,7 @@ export interface TypeMenuRootOwnProps {
12
18
  width?: TypePopoutProps["width"];
13
19
  }
14
20
  export interface TypeMenuRootProps
15
- extends Partial<TypeMenuProviderProps["menuProps"]>,
21
+ extends Partial<TypeMenuContextProps>,
16
22
  Pick<TypeMenuProviderProps, "children">,
17
23
  TypeMenuRootOwnProps {}
18
24
 
@@ -31,11 +37,18 @@ const CustomPopoutContent = styled(Popout.Content)`
31
37
  export const MenuRoot = ({
32
38
  children,
33
39
  menuToggleElement,
34
- popoutProps: { focusLockProps, ...popoutProps } = {},
40
+ popoutProps: { placement = "bottom", focusLockProps, ...popoutProps } = {},
35
41
  width = "300px",
36
42
  ...contextProps
37
43
  }: TypeMenuRootProps) => {
38
- const { isOpen, openMenu, closeMenu } = contextProps;
44
+ const {
45
+ isOpen,
46
+ openMenu,
47
+ closeMenu,
48
+ inputValue,
49
+ hiddenItemsCount = 0,
50
+ items,
51
+ } = contextProps;
39
52
  const setIsOpen = useCallback(
40
53
  (newIsOpen: boolean) => {
41
54
  newIsOpen ? openMenu?.() : closeMenu?.();
@@ -43,26 +56,41 @@ export const MenuRoot = ({
43
56
  [openMenu, closeMenu]
44
57
  );
45
58
 
59
+ useEffect(() => {
60
+ if (!inputValue) return;
61
+
62
+ if (items && hiddenItemsCount >= items.length && isOpen) {
63
+ closeMenu?.();
64
+ }
65
+ }, [hiddenItemsCount, items?.length, isOpen, inputValue]);
66
+
67
+ const menuContext = useMenu(contextProps);
68
+
46
69
  return (
47
- <MenuProvider menuProps={contextProps}>
48
- <Popout
49
- menuPopout
50
- isOpen={!!isOpen}
51
- setIsOpen={setIsOpen}
52
- content={
70
+ <Popout
71
+ menuPopout
72
+ placement={placement}
73
+ isOpen={!!isOpen}
74
+ setIsOpen={setIsOpen}
75
+ content={
76
+ <MenuContentProvider menuContext={menuContext}>
53
77
  <CustomPopoutContent width={width}>{children}</CustomPopoutContent>
54
- }
55
- focusLockProps={{
56
- // correct jerking motion when scrolling while the Popout is open
57
- // returnFocus: false,
58
- crossFrame: false,
59
- ...focusLockProps,
60
- }}
61
- disableWrapperAria
62
- {...popoutProps}
63
- >
64
- {menuToggleElement}
65
- </Popout>
66
- </MenuProvider>
78
+ </MenuContentProvider>
79
+ }
80
+ focusLockProps={{
81
+ // correct jerking motion when scrolling while the Popout is open
82
+ // returnFocus: false,
83
+ crossFrame: false,
84
+ ...focusLockProps,
85
+ }}
86
+ disableWrapperAria
87
+ {...popoutProps}
88
+ >
89
+ {(toggleProps) => (
90
+ <MenuToggleProvider menuContext={{ ...menuContext, ...toggleProps }}>
91
+ {menuToggleElement}
92
+ </MenuToggleProvider>
93
+ )}
94
+ </Popout>
67
95
  );
68
96
  };
@@ -4,34 +4,37 @@ import {
4
4
  render,
5
5
  screen,
6
6
  waitFor,
7
+ actRender,
8
+ act,
7
9
  } from "@sproutsocial/seeds-react-testing-library";
8
- import { Box } from "@sproutsocial/seeds-react-box";
9
10
  import { Button } from "@sproutsocial/seeds-react-button";
10
11
  import {
11
12
  MenuRoot,
12
13
  MenuToggleButton,
13
14
  MenuItem,
14
- MenuContext,
15
+ MenuContentContext,
16
+ MenuToggleContext,
15
17
  MenuFooter,
16
18
  MenuHeader,
17
19
  MenuGroup,
18
20
  type TypeMenuContextProps,
19
21
  } from "../../index";
22
+ import { testDefaultContext } from "../../menuTestingUtils";
20
23
 
21
24
  jest.mock("@sproutsocial/seeds-react-portal", () => ({ children }) => (
22
25
  <div>{children}</div>
23
26
  ));
24
27
 
25
28
  const TestComponent = () => {
26
- const { isOpen } = useContext(MenuContext);
29
+ const { isOpen } = useContext(MenuToggleContext);
27
30
  return <div>{isOpen ? "Is open" : "Is closed"}</div>;
28
31
  };
29
32
  const TestToggleButton = () => {
30
- const { getToggleButtonProps } = useContext(MenuContext);
33
+ const { getToggleButtonProps } = useContext(MenuToggleContext);
31
34
  return <Button {...getToggleButtonProps?.()}>Toggle</Button>;
32
35
  };
33
36
  const TestMenuFooter = () => {
34
- const { closeMenu } = useContext(MenuContext);
37
+ const { closeMenu } = useContext(MenuContentContext);
35
38
  return (
36
39
  <MenuFooter>
37
40
  <Button onClick={() => closeMenu?.()}>Close</Button>
@@ -39,14 +42,14 @@ const TestMenuFooter = () => {
39
42
  );
40
43
  };
41
44
  // const TestClearLink = () => {
42
- // const { reset } = useContext(MenuContext);
45
+ // const { reset } = useContext(MenuContentContext);
43
46
  // return <Link onClick={() => reset?.()}>Reset</Link>;
44
47
  // };
45
48
 
46
49
  describe("MenuRoot", () => {
47
50
  it("closes when esc is pressed", async () => {
48
51
  const mockCloseMenu = jest.fn();
49
- const { user } = render(
52
+ const { user } = await actRender(
50
53
  <MenuRoot
51
54
  menuToggleElement={
52
55
  <MenuToggleButton role="button" aria-label="Open Menu">
@@ -55,6 +58,7 @@ describe("MenuRoot", () => {
55
58
  }
56
59
  closeMenu={mockCloseMenu}
57
60
  isOpen
61
+ {...testDefaultContext()}
58
62
  >
59
63
  Menu
60
64
  </MenuRoot>
@@ -66,7 +70,9 @@ describe("MenuRoot", () => {
66
70
  expect(await screen.findByText("Menu")).toBeInTheDocument();
67
71
 
68
72
  // Press esc key
69
- await user.keyboard("{Esc}");
73
+ await act(async () => {
74
+ await user.keyboard("{Esc}");
75
+ });
70
76
 
71
77
  // Check that menu closes
72
78
  await waitFor(() => expect(mockCloseMenu).toHaveBeenCalled());
@@ -74,7 +80,7 @@ describe("MenuRoot", () => {
74
80
 
75
81
  it("should close when clicking outside of the menu", async () => {
76
82
  const mockCloseMenu = jest.fn();
77
- const { user } = render(
83
+ const { user } = await actRender(
78
84
  <div>
79
85
  <MenuRoot
80
86
  menuToggleElement={
@@ -84,6 +90,7 @@ describe("MenuRoot", () => {
84
90
  }
85
91
  closeMenu={mockCloseMenu}
86
92
  isOpen
93
+ {...testDefaultContext()}
87
94
  >
88
95
  Menu
89
96
  </MenuRoot>
@@ -95,7 +102,9 @@ describe("MenuRoot", () => {
95
102
 
96
103
  // Click outside the menu
97
104
  const otherButton = screen.getByRole("button", { name: "Outside" });
98
- await user.click(otherButton);
105
+ await act(async () => {
106
+ await user.click(otherButton);
107
+ });
99
108
 
100
109
  // Check that menu closes
101
110
  await waitFor(() => expect(mockCloseMenu).toHaveBeenCalled());
@@ -127,20 +136,23 @@ describe("MenuRoot", () => {
127
136
  </MenuToggleButton>
128
137
  }
129
138
  isOpen={isOpen}
139
+ {...testDefaultContext()}
130
140
  >
131
141
  Menu
132
142
  </MenuRoot>
133
143
  );
134
144
  };
135
- const { user } = render(<TestMenu />);
145
+ const { user } = await actRender(<TestMenu />);
136
146
 
137
- // Check that Menu is open
147
+ // Check that Menu is closed initially
138
148
  const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
139
149
  expect(menuTrigger).toBeInTheDocument();
140
150
  expect(screen.queryByText("Menu")).not.toBeInTheDocument();
141
151
 
142
152
  // Click the menu trigger
143
- await user.click(menuTrigger);
153
+ await act(async () => {
154
+ await user.click(menuTrigger);
155
+ });
144
156
 
145
157
  // Check that menu opens
146
158
  expect(await screen.findByText("Menu")).toBeInTheDocument();
@@ -163,12 +175,13 @@ describe("MenuRoot", () => {
163
175
  </MenuToggleButton>
164
176
  }
165
177
  isOpen={isOpen}
178
+ {...testDefaultContext()}
166
179
  >
167
180
  Menu
168
181
  </MenuRoot>
169
182
  );
170
183
  };
171
- const { user } = render(<TestMenu />);
184
+ const { user } = await actRender(<TestMenu />);
172
185
 
173
186
  // Check that Menu is open
174
187
  const menuTrigger = screen.getByRole("button", { name: "Open Menu" });
@@ -176,7 +189,9 @@ describe("MenuRoot", () => {
176
189
  expect(await screen.findByText("Menu")).toBeInTheDocument();
177
190
 
178
191
  // Click the menu trigger
179
- await user.click(menuTrigger);
192
+ await act(async () => {
193
+ await user.click(menuTrigger);
194
+ });
180
195
 
181
196
  // Check that menu opens
182
197
  await waitFor(() =>
@@ -187,18 +202,23 @@ describe("MenuRoot", () => {
187
202
  it("renders a custom toggle when passed to menuToggleElement", async () => {
188
203
  const mockToggleClick = jest.fn();
189
204
  const CustomToggleElement = () => {
190
- const { getToggleButtonProps } = useContext(MenuContext);
205
+ const { getToggleButtonProps } = useContext(MenuToggleContext);
191
206
  return (
192
207
  <div role="button" {...getToggleButtonProps?.()}>
193
208
  Toggle
194
209
  </div>
195
210
  );
196
211
  };
197
- const { user } = render(
212
+ const { user } = await actRender(
198
213
  <MenuRoot
199
214
  menuToggleElement={<CustomToggleElement />}
200
- // @ts-ignore - mock contextProps may be imperfect
201
- getToggleButtonProps={() => ({ onClick: mockToggleClick })}
215
+ {...testDefaultContext({
216
+ // @ts-ignore - mock contextProps may be imperfect
217
+ getToggleButtonProps: (props) => ({
218
+ onClick: mockToggleClick,
219
+ ...props,
220
+ }),
221
+ })}
202
222
  >
203
223
  Menu
204
224
  </MenuRoot>
@@ -206,16 +226,19 @@ describe("MenuRoot", () => {
206
226
 
207
227
  const menuTrigger = screen.getByRole("button", { name: "Toggle" });
208
228
  expect(menuTrigger).toBeInTheDocument();
209
- await user.click(menuTrigger);
229
+ await act(async () => {
230
+ await user.click(menuTrigger);
231
+ });
210
232
 
211
233
  expect(mockToggleClick).toHaveBeenCalled();
212
234
  });
213
235
 
214
- it("renders custom children", () => {
215
- render(
236
+ it("renders custom children", async () => {
237
+ await actRender(
216
238
  <MenuRoot
217
239
  menuToggleElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
218
240
  isOpen
241
+ {...testDefaultContext()}
219
242
  >
220
243
  <MenuHeader title="Menu Header" />
221
244
  <div>Custom Banner</div>
@@ -235,19 +258,18 @@ describe("MenuRoot", () => {
235
258
  ).toBeInTheDocument();
236
259
  });
237
260
 
238
- it("can be implemented without using Downshift", () => {
261
+ it("can be implemented without using Downshift", async () => {
239
262
  const randomText = "Some random text";
240
263
  const TestCustomMenu = () => {
241
264
  const [isOpen, setIsOpen] = useState(false);
242
- const getToggleButtonProps = (props) => ({
243
- onClick: () => setIsOpen(!isOpen),
244
- ...props,
245
- });
246
265
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
247
266
  // @ts-ignore - mock contextProps may be imperfect
248
267
  const contextProps: TypeMenuContextProps = {
249
268
  toggleMenu: () => setIsOpen(!isOpen),
250
- getToggleButtonProps,
269
+ getToggleButtonProps: (props) => ({
270
+ onClick: () => setIsOpen(!isOpen),
271
+ ...props,
272
+ }),
251
273
  isOpen,
252
274
  };
253
275
 
@@ -258,17 +280,22 @@ describe("MenuRoot", () => {
258
280
  Open Menu (<TestComponent />)
259
281
  </MenuToggleButton>
260
282
  }
261
- {...contextProps}
283
+ {...testDefaultContext(contextProps)}
262
284
  >
263
285
  {randomText}
264
286
  </MenuRoot>
265
287
  );
266
288
  };
267
- render(<TestCustomMenu />);
289
+
290
+ await actRender(<TestCustomMenu />);
268
291
 
269
292
  expect(screen.getByText("Is closed")).toBeInTheDocument();
270
293
  expect(screen.queryByText(randomText)).not.toBeInTheDocument();
271
- fireEvent.click(screen.getByRole("button"));
294
+
295
+ await act(async () => {
296
+ fireEvent.click(screen.getByRole("button"));
297
+ });
298
+
272
299
  expect(screen.getByText("Is open")).toBeInTheDocument();
273
300
  expect(screen.getByText(randomText)).toBeInTheDocument();
274
301
  });
@@ -316,7 +343,7 @@ describe("MenuRoot", () => {
316
343
  <MenuRoot
317
344
  menuToggleElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
318
345
  isOpen
319
- {...contextProps}
346
+ {...testDefaultContext(contextProps)}
320
347
  >
321
348
  <TestComponent />
322
349
  </MenuRoot>