@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +72 -0
- package/dist/esm/index.js +1529 -440
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.mts +275 -92
- package/dist/index.d.ts +275 -92
- package/dist/index.js +1558 -444
- package/dist/index.js.map +1 -1
- package/package.json +15 -12
- package/src/ActionMenu/ActionMenu.stories.tsx +422 -0
- package/src/ActionMenu/ActionMenu.tsx +24 -27
- package/src/ActionMenu/ActionMenuTypes.ts +8 -7
- package/src/ActionMenu/__tests__/ActionMenu.test.tsx +17 -8
- package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +8 -8
- package/src/Autocomplete/Autocomplete.stories.tsx +38 -3
- package/src/Autocomplete/Autocomplete.tsx +15 -80
- package/src/Autocomplete/AutocompleteInput.tsx +18 -27
- package/src/Autocomplete/AutocompleteTokenInput.tsx +50 -0
- package/src/Autocomplete/AutocompleteTypes.ts +25 -12
- package/src/Autocomplete/MultiAutocomplete.tsx +188 -0
- package/src/Autocomplete/SingleAutocomplete.tsx +74 -0
- package/src/Autocomplete/__tests__/Autocomplete.test.tsx +207 -3
- package/src/Autocomplete/__tests__/Autocomplete.typetest.tsx +90 -8
- package/src/MenuContent/MenuContent.stories.tsx +164 -0
- package/src/MenuContent/MenuContent.tsx +28 -12
- package/src/MenuContent/MenuContentTypes.ts +6 -4
- package/src/MenuContent/__tests__/MenuContent.typetest.tsx +8 -8
- package/src/MenuContext.tsx +192 -37
- package/src/MenuFooter/MenuFooter.stories.tsx +202 -0
- package/src/MenuFooter/__tests__/MenuFooter.test.tsx +16 -11
- package/src/MenuFooter/styles.tsx +1 -1
- package/src/MenuGroup/MenuGroup.feature +16 -16
- package/src/MenuGroup/MenuGroup.stories.tsx +248 -0
- package/src/MenuGroup/MenuGroup.tsx +12 -7
- package/src/MenuGroup/MenuGroupTypes.ts +6 -2
- package/src/MenuGroup/__tests__/MenuGroup.test.tsx +6 -6
- package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +7 -7
- package/src/MenuGroup/styles.tsx +4 -2
- package/src/MenuHeader/MenuHeader.stories.tsx +276 -0
- package/src/MenuHeader/MenuHeader.tsx +12 -7
- package/src/MenuHeader/__tests__/MenuHeader.test.tsx +23 -13
- package/src/MenuHeader/styles.tsx +22 -14
- package/src/MenuItem/MenuItem.stories.tsx +507 -0
- package/src/MenuItem/MenuItem.tsx +23 -9
- package/src/MenuItem/MenuItemTypes.ts +9 -4
- package/src/MenuItem/__tests__/MenuItem.test.tsx +6 -6
- package/src/MenuItem/styles.tsx +23 -5
- package/src/MenuItem/useOptionProps.tsx +11 -9
- package/src/MenuLabel.tsx +2 -2
- package/src/MenuRoot/MenuRoot.tsx +52 -24
- package/src/MenuRoot/__tests__/MenuRoot.test.tsx +59 -32
- package/src/MenuSearchInput/MenuSearchInput.tsx +85 -0
- package/src/MenuSearchInput/__tests__/MenuSearchInput.test.tsx +157 -0
- package/src/MenuSearchInput/__tests__/MenuSearchInput.typetest.tsx +31 -0
- package/src/MenuSearchInput/index.ts +1 -0
- package/src/MenuSearchTokenInput/MenuSearchTokenInput.tsx +108 -0
- package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.test.tsx +247 -0
- package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.typetest.tsx +33 -0
- package/src/MenuSearchTokenInput/index.ts +1 -0
- package/src/MenuToggleButton/MenuToggleButton.stories.tsx +72 -0
- package/src/{MenuToggleButton.tsx → MenuToggleButton/MenuToggleButton.tsx} +6 -5
- package/src/MenuToggleButton/index.ts +1 -0
- package/src/MenuTokenInput.tsx +104 -0
- package/src/MultiSelectMenu/MultiSelectMenu.stories.tsx +222 -0
- package/src/MultiSelectMenu/MultiSelectMenu.tsx +38 -68
- package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +8 -6
- package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +46 -17
- package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +6 -6
- package/src/NestedMenu/NestedMenu.feature +109 -0
- package/src/NestedMenu/NestedMenu.stories.tsx +157 -0
- package/src/NestedMenu/NestedMenu.tsx +102 -0
- package/src/NestedMenu/NestedMenuContent.tsx +75 -0
- package/src/NestedMenu/NestedMenuContext.tsx +56 -0
- package/src/NestedMenu/NestedMenuHeader.tsx +25 -0
- package/src/NestedMenu/NestedMenuItem.tsx +41 -0
- package/src/NestedMenu/NestedMenuTypes.ts +47 -0
- package/src/NestedMenu/__tests__/NestedMenu.test.tsx +31 -0
- package/src/NestedMenu/__tests__/NestedMenu.typetest.tsx +134 -0
- package/src/NestedMenu/index.ts +5 -0
- package/src/SingleSelectMenu/SingleSelectMenu.stories.tsx +233 -0
- package/src/SingleSelectMenu/SingleSelectMenu.tsx +23 -26
- package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +7 -5
- package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +6 -6
- package/src/SubmenuItem/SubmenuItem.feature +82 -0
- package/src/SubmenuItem/SubmenuItem.stories.tsx +163 -0
- package/src/SubmenuItem/SubmenuItem.tsx +258 -0
- package/src/SubmenuItem/SubmenuItemTypes.ts +24 -0
- package/src/SubmenuItem/__tests__/SubmenuItem.test.tsx +3 -0
- package/src/SubmenuItem/__tests__/SubmenuItem.typetest.tsx +112 -0
- package/src/SubmenuItem/index.ts +2 -0
- package/src/hooks/useDownshiftDefaults.tsx +117 -0
- package/src/hooks/useItemFiltering.tsx +68 -0
- package/src/hooks/useMenuChildren.tsx +205 -131
- package/src/index.ts +6 -0
- package/src/menuTestingUtils.tsx +21 -10
- package/src/reducers/nestedMenuStateReducer.tsx +23 -0
- package/src/reducers/useComboboxStateReducer.tsx +26 -8
- package/src/reducers/useSelectStateReducer.tsx +24 -6
- package/src/storybookUtilities/menu-storybook-components.tsx +5 -3
- package/src/types.ts +38 -30
- package/src/utils/downshiftDefaults.ts +9 -0
- package/src/utils/getMultiSelectOverrides.ts +21 -0
- package/src/utils/reduceReducers.ts +19 -0
- package/src/utils/itemToString.ts +0 -5
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
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
|
-
|
|
14
|
+
const MenuItem = ({
|
|
15
15
|
id,
|
|
16
16
|
children,
|
|
17
17
|
onClick,
|
|
18
18
|
onKeyDown,
|
|
19
19
|
disabled = false,
|
|
20
|
-
|
|
20
|
+
active,
|
|
21
|
+
innerRef,
|
|
21
22
|
href,
|
|
22
23
|
color,
|
|
23
24
|
...props
|
|
24
25
|
}: TypeMenuItemProps) => {
|
|
25
|
-
const {
|
|
26
|
-
|
|
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
|
|
38
|
+
const item = (itemsMap[id] || { index: 0, id }) as TypeInternalItemProps;
|
|
33
39
|
|
|
34
40
|
const highlighted = highlightedIndex === item?.index;
|
|
35
41
|
|
|
36
|
-
return item.
|
|
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
|
-
//
|
|
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 {
|
|
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<
|
|
37
|
-
|
|
36
|
+
GetItemPropsOptions<TypeInternalItemProps>,
|
|
37
|
+
| "as"
|
|
38
|
+
| "item"
|
|
39
|
+
| "ref"
|
|
40
|
+
| keyof TypeMenuItemStyledProps
|
|
41
|
+
| keyof TypeInternalItemProps
|
|
38
42
|
>,
|
|
39
|
-
Omit<
|
|
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({
|
package/src/MenuItem/styles.tsx
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 {
|
|
8
|
+
import { useMenuContentContext } from "../MenuContext";
|
|
9
9
|
|
|
10
10
|
import type { TypeMenuItemProps, InputType } from "./MenuItemTypes";
|
|
11
|
-
import {
|
|
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,
|
|
86
|
+
const { selectedItem, selectedItemIds, isListbox, isItemSelected } =
|
|
87
|
+
useMenuContentContext();
|
|
84
88
|
const selected = useMemo(
|
|
85
|
-
() =>
|
|
86
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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 {
|
|
3
|
+
import { useMenuToggleContext } from "./MenuContext";
|
|
4
4
|
|
|
5
5
|
export const MenuLabel = ({ children, ...rest }: Partial<TypeLabelProps>) => {
|
|
6
|
-
const { getLabelProps } =
|
|
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,
|
|
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 {
|
|
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<
|
|
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 {
|
|
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
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
33
|
+
const { getToggleButtonProps } = useContext(MenuToggleContext);
|
|
31
34
|
return <Button {...getToggleButtonProps?.()}>Toggle</Button>;
|
|
32
35
|
};
|
|
33
36
|
const TestMenuFooter = () => {
|
|
34
|
-
const { closeMenu } = useContext(
|
|
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(
|
|
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 } =
|
|
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
|
|
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 } =
|
|
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
|
|
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 } =
|
|
145
|
+
const { user } = await actRender(<TestMenu />);
|
|
136
146
|
|
|
137
|
-
// Check that Menu is
|
|
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
|
|
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 } =
|
|
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
|
|
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(
|
|
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 } =
|
|
212
|
+
const { user } = await actRender(
|
|
198
213
|
<MenuRoot
|
|
199
214
|
menuToggleElement={<CustomToggleElement />}
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
289
|
+
|
|
290
|
+
await actRender(<TestCustomMenu />);
|
|
268
291
|
|
|
269
292
|
expect(screen.getByText("Is closed")).toBeInTheDocument();
|
|
270
293
|
expect(screen.queryByText(randomText)).not.toBeInTheDocument();
|
|
271
|
-
|
|
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>
|