@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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Input, type TypeInputProps } from "@sproutsocial/seeds-react-input";
|
|
3
|
+
import {
|
|
4
|
+
useItemFiltering,
|
|
5
|
+
type TypeGetIsItemVisibleFn,
|
|
6
|
+
} from "../hooks/useItemFiltering";
|
|
7
|
+
import {
|
|
8
|
+
useMenuContentContext,
|
|
9
|
+
type TypeDownshiftSelectReturnValue,
|
|
10
|
+
} from "../MenuContext";
|
|
11
|
+
|
|
12
|
+
export interface TypeMenuSearchInputProps extends TypeInputProps {
|
|
13
|
+
/* A function that can be passed to override the default filtering logic */
|
|
14
|
+
getIsItemVisible?: TypeGetIsItemVisibleFn;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* <input
|
|
19
|
+
* aria-activedescendant=""
|
|
20
|
+
* aria-autocomplete="list"
|
|
21
|
+
* aria-controls="downshift-:r1:-menu"
|
|
22
|
+
* aria-expanded="true"
|
|
23
|
+
* />
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @link https://seeds.sproutsocial.com/components/menu-header/
|
|
28
|
+
*
|
|
29
|
+
* @description MenuSearchInput is search input that can be added to the MenuHeader to filter the items.
|
|
30
|
+
*/
|
|
31
|
+
export const MenuSearchInput = ({
|
|
32
|
+
getIsItemVisible,
|
|
33
|
+
inputProps,
|
|
34
|
+
...restInputProps
|
|
35
|
+
}: TypeMenuSearchInputProps) => {
|
|
36
|
+
const {
|
|
37
|
+
items,
|
|
38
|
+
itemToString,
|
|
39
|
+
setHiddenItemsMap,
|
|
40
|
+
isOpen,
|
|
41
|
+
getToggleButtonProps,
|
|
42
|
+
} = useMenuContentContext();
|
|
43
|
+
const { updateFilteredItems } = useItemFiltering({
|
|
44
|
+
allMenuItems: items,
|
|
45
|
+
itemToString,
|
|
46
|
+
getIsItemVisible,
|
|
47
|
+
setHiddenItemsMap,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
// handles when open/close state changes or if input is controlled
|
|
52
|
+
updateFilteredItems({ inputValue: restInputProps.value || "" });
|
|
53
|
+
}, [isOpen, restInputProps.value]);
|
|
54
|
+
|
|
55
|
+
const toggleButtonProps: Partial<
|
|
56
|
+
ReturnType<Required<TypeDownshiftSelectReturnValue>["getToggleButtonProps"]>
|
|
57
|
+
> = getToggleButtonProps?.() || {};
|
|
58
|
+
|
|
59
|
+
const handleKeyDown = React.useCallback(
|
|
60
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
61
|
+
// prevents selecting items with space key when input is focused
|
|
62
|
+
if (e.key === " ") return;
|
|
63
|
+
toggleButtonProps.onKeyDown?.(e);
|
|
64
|
+
},
|
|
65
|
+
[toggleButtonProps.onKeyDown]
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Input
|
|
70
|
+
onChange={(e, value) => {
|
|
71
|
+
updateFilteredItems({ inputValue: value });
|
|
72
|
+
}}
|
|
73
|
+
// apply onKeyDown logic for downshift keyboard accessibility support
|
|
74
|
+
onKeyDown={handleKeyDown}
|
|
75
|
+
autoComplete={false}
|
|
76
|
+
aria-autocomplete="list"
|
|
77
|
+
inputProps={{
|
|
78
|
+
["aria-activedescendant"]: toggleButtonProps["aria-activedescendant"],
|
|
79
|
+
["aria-controls"]: toggleButtonProps["aria-controls"],
|
|
80
|
+
...inputProps,
|
|
81
|
+
}}
|
|
82
|
+
{...restInputProps}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
act,
|
|
7
|
+
} from "@sproutsocial/seeds-react-testing-library";
|
|
8
|
+
import {
|
|
9
|
+
MenuHeader,
|
|
10
|
+
MenuContent,
|
|
11
|
+
MenuItem,
|
|
12
|
+
MenuToggleButton,
|
|
13
|
+
ActionMenu,
|
|
14
|
+
MultiSelectMenu,
|
|
15
|
+
SingleSelectMenu,
|
|
16
|
+
type TypeSingleSelectMenuProps,
|
|
17
|
+
type TypeMenuRootProps,
|
|
18
|
+
} from "../../index";
|
|
19
|
+
import {
|
|
20
|
+
MenuSearchInput,
|
|
21
|
+
type TypeMenuSearchInputProps,
|
|
22
|
+
} from "../MenuSearchInput";
|
|
23
|
+
|
|
24
|
+
const menuItems = [
|
|
25
|
+
{ id: "item-0", index: 0, label: "Item 0" },
|
|
26
|
+
{ id: "item-1", index: 1, label: "Item 1" },
|
|
27
|
+
{ id: "item-2", index: 2, label: "Item 2" },
|
|
28
|
+
{ id: "item-3", index: 3, label: "Item 3" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const itemToString = (item) => (item ? menuItems[item.index].label : "");
|
|
32
|
+
const defaultProps = (
|
|
33
|
+
props: Partial<TypeMenuSearchInputProps> = {}
|
|
34
|
+
): TypeSingleSelectMenuProps => ({
|
|
35
|
+
menuToggleElement: (
|
|
36
|
+
<MenuToggleButton role="button" aria-label="Open Menu">
|
|
37
|
+
Open
|
|
38
|
+
</MenuToggleButton>
|
|
39
|
+
),
|
|
40
|
+
itemToString: (item) => (item ? menuItems[item.index].label : ""),
|
|
41
|
+
children: (
|
|
42
|
+
<>
|
|
43
|
+
<MenuHeader title="Select Items">
|
|
44
|
+
<MenuSearchInput
|
|
45
|
+
id="search"
|
|
46
|
+
name="search"
|
|
47
|
+
type="search"
|
|
48
|
+
aria-label="Search"
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
</MenuHeader>
|
|
52
|
+
<MenuContent>
|
|
53
|
+
{menuItems.map(({ id, label }) => (
|
|
54
|
+
<MenuItem key={id} id={id}>
|
|
55
|
+
{label}
|
|
56
|
+
</MenuItem>
|
|
57
|
+
))}
|
|
58
|
+
</MenuContent>
|
|
59
|
+
</>
|
|
60
|
+
),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("MenuSearchInput", () => {
|
|
64
|
+
describe.each([
|
|
65
|
+
{ name: "ActionMenu", Component: ActionMenu },
|
|
66
|
+
{ name: "MultiSelectMenu", Component: MultiSelectMenu },
|
|
67
|
+
{ name: "SingleSelectMenu", Component: SingleSelectMenu },
|
|
68
|
+
])("when in a $name", ({ name, Component }) => {
|
|
69
|
+
const menuItemRole = name === "ActionMenu" ? "menuitem" : "option";
|
|
70
|
+
it("should display suggestions when typing", async () => {
|
|
71
|
+
const { user } = render(<Component {...defaultProps()} />);
|
|
72
|
+
|
|
73
|
+
// Click the menu trigger
|
|
74
|
+
const menuTrigger = screen.getByRole("button", {
|
|
75
|
+
name: "Open Menu",
|
|
76
|
+
});
|
|
77
|
+
await user.click(menuTrigger);
|
|
78
|
+
|
|
79
|
+
// Type into the search input
|
|
80
|
+
const inputElement = screen.getByRole("searchbox") as HTMLInputElement;
|
|
81
|
+
await act(async () => {
|
|
82
|
+
await user.click(inputElement);
|
|
83
|
+
await user.type(inputElement, "item");
|
|
84
|
+
});
|
|
85
|
+
await waitFor(() => {
|
|
86
|
+
const suggestions = screen.getAllByRole(menuItemRole);
|
|
87
|
+
expect(suggestions.length).toBeGreaterThan(0);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("should select a suggestion when activated by the keyboard", async () => {
|
|
92
|
+
const handleSelectedItemChange = jest.fn();
|
|
93
|
+
const { user } = render(
|
|
94
|
+
<Component
|
|
95
|
+
onSelectedItemChange={handleSelectedItemChange}
|
|
96
|
+
{...defaultProps()}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Click the menu trigger
|
|
101
|
+
const menuTrigger = screen.getByRole("button", {
|
|
102
|
+
name: "Open Menu",
|
|
103
|
+
});
|
|
104
|
+
await user.click(menuTrigger);
|
|
105
|
+
|
|
106
|
+
// Type into the search input
|
|
107
|
+
const inputElement = screen.getByRole("searchbox") as HTMLInputElement;
|
|
108
|
+
await act(async () => {
|
|
109
|
+
await user.click(inputElement);
|
|
110
|
+
await user.type(inputElement, "item");
|
|
111
|
+
});
|
|
112
|
+
await act(async () => {
|
|
113
|
+
await user.keyboard("{arrowdown}");
|
|
114
|
+
await user.keyboard("{enter}");
|
|
115
|
+
});
|
|
116
|
+
await waitFor(() => expect(handleSelectedItemChange).toHaveBeenCalled());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should use the custom filter function when getIsItemVisible is passed", async () => {
|
|
120
|
+
const customFilter: TypeMenuSearchInputProps["getIsItemVisible"] =
|
|
121
|
+
jest.fn((item, inputValue) =>
|
|
122
|
+
itemToString(item).toLowerCase().startsWith(inputValue.toLowerCase())
|
|
123
|
+
);
|
|
124
|
+
const { user } = render(
|
|
125
|
+
<Component {...defaultProps({ getIsItemVisible: customFilter })} />
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Click the menu trigger
|
|
129
|
+
const menuTrigger = screen.getByRole("button", {
|
|
130
|
+
name: "Open Menu",
|
|
131
|
+
});
|
|
132
|
+
await user.click(menuTrigger);
|
|
133
|
+
|
|
134
|
+
// Type into the search input
|
|
135
|
+
const inputElement = screen.getByRole("searchbox") as HTMLInputElement;
|
|
136
|
+
await act(async () => {
|
|
137
|
+
await user.click(inputElement);
|
|
138
|
+
await user.type(inputElement, "item 0");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await waitFor(() =>
|
|
142
|
+
expect(screen.queryAllByRole(menuItemRole).length).toBe(1)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await act(async () => {
|
|
146
|
+
await user.clear(inputElement);
|
|
147
|
+
await user.type(inputElement, "0");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await waitFor(() =>
|
|
151
|
+
expect(screen.queryAllByRole(menuItemRole).length).toBe(0)
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(customFilter).toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { MenuSearchInput } from "../MenuSearchInput";
|
|
2
|
+
|
|
3
|
+
export const MenuSearchInputTypeTest = () => (
|
|
4
|
+
<>
|
|
5
|
+
{/* Valid with required props */}
|
|
6
|
+
<MenuSearchInput id="id" name="name" />
|
|
7
|
+
{/* Valid with optional input props */}
|
|
8
|
+
<MenuSearchInput
|
|
9
|
+
id="id"
|
|
10
|
+
name="name"
|
|
11
|
+
type="text"
|
|
12
|
+
aria-label="Search"
|
|
13
|
+
value=""
|
|
14
|
+
onChange={() => {}}
|
|
15
|
+
/>
|
|
16
|
+
{/* Valid with optional getIsItemVisible prop */}
|
|
17
|
+
<MenuSearchInput
|
|
18
|
+
id="id"
|
|
19
|
+
name="name"
|
|
20
|
+
getIsItemVisible={(item, inputValue) => item.id.includes(inputValue)}
|
|
21
|
+
/>
|
|
22
|
+
<MenuSearchInput
|
|
23
|
+
id="id"
|
|
24
|
+
name="name"
|
|
25
|
+
/* @ts-expect-error - getIsItemVisible must be a valid function */
|
|
26
|
+
getIsItemVisible=""
|
|
27
|
+
/* @ts-expect-error - onChange must be a valid function */
|
|
28
|
+
onChange=""
|
|
29
|
+
/>
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./MenuSearchInput";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
useItemFiltering,
|
|
4
|
+
type TypeGetIsItemVisibleFn,
|
|
5
|
+
} from "../hooks/useItemFiltering";
|
|
6
|
+
import {
|
|
7
|
+
MenuContentContext,
|
|
8
|
+
useMenuContentContext,
|
|
9
|
+
type TypeDownshiftSelectReturnValue,
|
|
10
|
+
} from "../MenuContext";
|
|
11
|
+
import {
|
|
12
|
+
MenuTokenInput,
|
|
13
|
+
type TypeMenuTokenInputProps,
|
|
14
|
+
} from "../MenuTokenInput";
|
|
15
|
+
|
|
16
|
+
export interface TypeMenuSearchTokenInputProps extends TypeMenuTokenInputProps {
|
|
17
|
+
/* A function that can be passed to override the default filtering logic */
|
|
18
|
+
getIsItemVisible?: TypeGetIsItemVisibleFn;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @link https://seeds.sproutsocial.com/components/menu-search-token-input/
|
|
23
|
+
*
|
|
24
|
+
* @description MenuSearchTokenInput is search token input that can be added to the MenuHeader to filter the items.
|
|
25
|
+
*/
|
|
26
|
+
export const MenuSearchTokenInput = ({
|
|
27
|
+
getIsItemVisible,
|
|
28
|
+
getTokenProps,
|
|
29
|
+
onChange,
|
|
30
|
+
inputProps: { value: inputPropsValue, ...inputProps } = {},
|
|
31
|
+
value,
|
|
32
|
+
...tokenInputProps
|
|
33
|
+
}: TypeMenuSearchTokenInputProps) => {
|
|
34
|
+
const {
|
|
35
|
+
items,
|
|
36
|
+
itemToString,
|
|
37
|
+
setHiddenItemsMap,
|
|
38
|
+
isOpen,
|
|
39
|
+
getToggleButtonProps,
|
|
40
|
+
} = useMenuContentContext();
|
|
41
|
+
const { updateFilteredItems } = useItemFiltering({
|
|
42
|
+
allMenuItems: items,
|
|
43
|
+
itemToString,
|
|
44
|
+
getIsItemVisible,
|
|
45
|
+
setHiddenItemsMap,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// set controlled value
|
|
49
|
+
const controlledValue =
|
|
50
|
+
value || inputPropsValue !== undefined ? `${inputPropsValue}` : undefined;
|
|
51
|
+
|
|
52
|
+
// internal state helps to keep track of the input value since it sometimes resets on its own
|
|
53
|
+
const [internalValue, setInternalValue] = React.useState(
|
|
54
|
+
controlledValue || ""
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// any time state is updated, also update the filtered items
|
|
58
|
+
const updateFilteredState = React.useCallback(
|
|
59
|
+
// defaults to an empty string if no value is passed
|
|
60
|
+
(value: string = "") => {
|
|
61
|
+
setInternalValue(value);
|
|
62
|
+
updateFilteredItems({ inputValue: value });
|
|
63
|
+
},
|
|
64
|
+
[updateFilteredItems]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
// handles when input is controlled to keep internal state updated
|
|
69
|
+
updateFilteredState(controlledValue);
|
|
70
|
+
}, [controlledValue]);
|
|
71
|
+
|
|
72
|
+
React.useEffect(() => {
|
|
73
|
+
// handles when open/close state changes
|
|
74
|
+
updateFilteredState(controlledValue);
|
|
75
|
+
}, [isOpen]);
|
|
76
|
+
|
|
77
|
+
const toggleButtonProps: Partial<
|
|
78
|
+
ReturnType<Required<TypeDownshiftSelectReturnValue>["getToggleButtonProps"]>
|
|
79
|
+
> = getToggleButtonProps?.() || {};
|
|
80
|
+
|
|
81
|
+
const handleKeyDown = React.useCallback(
|
|
82
|
+
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
83
|
+
// prevents selecting items with space key when input is focused
|
|
84
|
+
if (e.key === " ") return;
|
|
85
|
+
toggleButtonProps.onKeyDown?.(e);
|
|
86
|
+
},
|
|
87
|
+
[toggleButtonProps.onKeyDown]
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<MenuTokenInput
|
|
92
|
+
onChange={(e, value) => {
|
|
93
|
+
updateFilteredState(value);
|
|
94
|
+
onChange?.(e, value);
|
|
95
|
+
}}
|
|
96
|
+
// apply onKeyDown logic for downshift keyboard accessibility support
|
|
97
|
+
onKeyDown={handleKeyDown}
|
|
98
|
+
inputProps={{
|
|
99
|
+
value: internalValue,
|
|
100
|
+
["aria-activedescendant"]: toggleButtonProps["aria-activedescendant"],
|
|
101
|
+
["aria-controls"]: toggleButtonProps["aria-controls"],
|
|
102
|
+
...inputProps,
|
|
103
|
+
}}
|
|
104
|
+
MenuContext={MenuContentContext}
|
|
105
|
+
{...tokenInputProps}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
render,
|
|
4
|
+
screen,
|
|
5
|
+
waitFor,
|
|
6
|
+
act,
|
|
7
|
+
} from "@sproutsocial/seeds-react-testing-library";
|
|
8
|
+
import {
|
|
9
|
+
MenuHeader,
|
|
10
|
+
MenuContent,
|
|
11
|
+
MenuItem,
|
|
12
|
+
MenuToggleButton,
|
|
13
|
+
ActionMenu,
|
|
14
|
+
MultiSelectMenu,
|
|
15
|
+
SingleSelectMenu,
|
|
16
|
+
type TypeSingleSelectMenuProps,
|
|
17
|
+
type TypeMenuRootProps,
|
|
18
|
+
} from "../../index";
|
|
19
|
+
import {
|
|
20
|
+
MenuSearchTokenInput,
|
|
21
|
+
type TypeMenuSearchTokenInputProps,
|
|
22
|
+
} from "../MenuSearchTokenInput";
|
|
23
|
+
|
|
24
|
+
const menuItems = [
|
|
25
|
+
{ id: "item-0", index: 0, label: "Item 0" },
|
|
26
|
+
{ id: "item-1", index: 1, label: "Item 1" },
|
|
27
|
+
{ id: "item-2", index: 2, label: "Item 2" },
|
|
28
|
+
{ id: "item-3", index: 3, label: "Item 3" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const itemToString = (item) => (item ? menuItems[item.index].label : "");
|
|
32
|
+
const defaultProps = (
|
|
33
|
+
props: Partial<TypeMenuSearchTokenInputProps> = {}
|
|
34
|
+
): TypeSingleSelectMenuProps => ({
|
|
35
|
+
menuToggleElement: (
|
|
36
|
+
<MenuToggleButton role="button" aria-label="Open Menu">
|
|
37
|
+
Open
|
|
38
|
+
</MenuToggleButton>
|
|
39
|
+
),
|
|
40
|
+
itemToString: (item) => (item ? menuItems[item.index].label : ""),
|
|
41
|
+
children: (
|
|
42
|
+
<>
|
|
43
|
+
<MenuHeader title="Select Items">
|
|
44
|
+
<MenuSearchTokenInput
|
|
45
|
+
id="search"
|
|
46
|
+
name="search"
|
|
47
|
+
aria-label="Search"
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
</MenuHeader>
|
|
51
|
+
<MenuContent>
|
|
52
|
+
{menuItems.map(({ id, label }) => (
|
|
53
|
+
<MenuItem key={id} id={id}>
|
|
54
|
+
{label}
|
|
55
|
+
</MenuItem>
|
|
56
|
+
))}
|
|
57
|
+
</MenuContent>
|
|
58
|
+
</>
|
|
59
|
+
),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("MenuSearchTokenInput", () => {
|
|
63
|
+
describe.each([
|
|
64
|
+
{ name: "ActionMenu", Component: ActionMenu },
|
|
65
|
+
{ name: "MultiSelectMenu", Component: MultiSelectMenu },
|
|
66
|
+
{ name: "SingleSelectMenu", Component: SingleSelectMenu },
|
|
67
|
+
])("when in a $name", ({ name, Component }) => {
|
|
68
|
+
const menuItemRole = name === "ActionMenu" ? "menuitem" : "option";
|
|
69
|
+
it("should display suggestions when typing", async () => {
|
|
70
|
+
const { user } = render(<Component {...defaultProps()} />);
|
|
71
|
+
|
|
72
|
+
// Click the menu trigger
|
|
73
|
+
const menuTrigger = screen.getByRole("button", {
|
|
74
|
+
name: "Open Menu",
|
|
75
|
+
});
|
|
76
|
+
await user.click(menuTrigger);
|
|
77
|
+
|
|
78
|
+
// Type into the search input
|
|
79
|
+
const inputElement = screen.getByRole("searchbox") as HTMLInputElement;
|
|
80
|
+
await act(async () => {
|
|
81
|
+
await user.click(inputElement);
|
|
82
|
+
await user.type(inputElement, "item");
|
|
83
|
+
});
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
const suggestions = screen.getAllByRole(menuItemRole);
|
|
86
|
+
expect(suggestions.length).toBeGreaterThan(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should select a suggestion when activated by the keyboard", async () => {
|
|
91
|
+
const handleSelectedItemChange = jest.fn();
|
|
92
|
+
const { user } = render(
|
|
93
|
+
<Component
|
|
94
|
+
onSelectedItemChange={handleSelectedItemChange}
|
|
95
|
+
{...defaultProps()}
|
|
96
|
+
/>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Click the menu trigger
|
|
100
|
+
const menuTrigger = screen.getByRole("button", {
|
|
101
|
+
name: "Open Menu",
|
|
102
|
+
});
|
|
103
|
+
await user.click(menuTrigger);
|
|
104
|
+
|
|
105
|
+
// Type into the search input
|
|
106
|
+
const inputElement = screen.getByRole("searchbox") as HTMLInputElement;
|
|
107
|
+
await act(async () => {
|
|
108
|
+
await user.click(inputElement);
|
|
109
|
+
await user.type(inputElement, "item");
|
|
110
|
+
});
|
|
111
|
+
await act(async () => {
|
|
112
|
+
await user.keyboard("{arrowdown}");
|
|
113
|
+
await user.keyboard("{enter}");
|
|
114
|
+
});
|
|
115
|
+
await waitFor(() => expect(handleSelectedItemChange).toHaveBeenCalled());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should use the custom filter function when getIsItemVisible is passed", async () => {
|
|
119
|
+
const customFilter: TypeMenuSearchTokenInputProps["getIsItemVisible"] =
|
|
120
|
+
jest.fn((item, inputValue) =>
|
|
121
|
+
itemToString(item).toLowerCase().startsWith(inputValue.toLowerCase())
|
|
122
|
+
);
|
|
123
|
+
const { user } = render(
|
|
124
|
+
<Component {...defaultProps({ getIsItemVisible: customFilter })} />
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Click the menu trigger
|
|
128
|
+
const menuTrigger = screen.getByRole("button", {
|
|
129
|
+
name: "Open Menu",
|
|
130
|
+
});
|
|
131
|
+
await user.click(menuTrigger);
|
|
132
|
+
|
|
133
|
+
// Type into the search input
|
|
134
|
+
const inputElement = screen.getByRole("searchbox") as HTMLInputElement;
|
|
135
|
+
await act(async () => {
|
|
136
|
+
await user.click(inputElement);
|
|
137
|
+
await user.type(inputElement, "item 0");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await waitFor(() =>
|
|
141
|
+
expect(screen.queryAllByRole(menuItemRole).length).toBe(1)
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await act(async () => {
|
|
145
|
+
await user.clear(inputElement);
|
|
146
|
+
await user.type(inputElement, "0");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await waitFor(() =>
|
|
150
|
+
expect(screen.queryAllByRole(menuItemRole).length).toBe(0)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(customFilter).toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("when in a MultiSelectMenu", () => {
|
|
158
|
+
it("should render multiple selected items", () => {
|
|
159
|
+
render(
|
|
160
|
+
<MultiSelectMenu
|
|
161
|
+
{...defaultProps()}
|
|
162
|
+
initialSelectedItems={[menuItems[0], menuItems[1]]}
|
|
163
|
+
isOpen
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// The menu trigger is also a button so can't be queried by the role
|
|
168
|
+
const selectedItems = screen.getAllByDataQaLabel({
|
|
169
|
+
"token-isdisabled": "false",
|
|
170
|
+
});
|
|
171
|
+
expect(selectedItems.length).toBe(2);
|
|
172
|
+
expect(selectedItems[0]).toHaveTextContent(menuItems[0].label);
|
|
173
|
+
expect(selectedItems[1]).toHaveTextContent(menuItems[1].label);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should add an item to the selected items list", async () => {
|
|
177
|
+
const { user } = render(
|
|
178
|
+
<MultiSelectMenu
|
|
179
|
+
{...defaultProps()}
|
|
180
|
+
initialSelectedItems={[menuItems[0]]}
|
|
181
|
+
isOpen
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Wait for the popout to finish opening
|
|
186
|
+
await waitFor(() =>
|
|
187
|
+
expect(
|
|
188
|
+
screen.getAllByDataQaLabel({
|
|
189
|
+
"popout-isopen": "true",
|
|
190
|
+
}).length
|
|
191
|
+
).toBe(1)
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Type into the search input
|
|
195
|
+
const inputElement = screen.getByRole("searchbox") as HTMLInputElement;
|
|
196
|
+
await act(async () => {
|
|
197
|
+
await user.click(inputElement);
|
|
198
|
+
await user.type(inputElement, "item 1");
|
|
199
|
+
await user.keyboard("{arrowdown}");
|
|
200
|
+
await user.keyboard("{enter}");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// The menu trigger is also a button so can't be queried by the role
|
|
204
|
+
const selectedItems = screen.getAllByDataQaLabel({
|
|
205
|
+
"token-isdisabled": "false",
|
|
206
|
+
});
|
|
207
|
+
screen.debug();
|
|
208
|
+
expect(selectedItems.length).toBe(2);
|
|
209
|
+
expect(selectedItems[1]).toHaveTextContent(menuItems[1].label);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should remove an item from the selected items list", async () => {
|
|
213
|
+
const handleChange = jest.fn();
|
|
214
|
+
const { user } = render(
|
|
215
|
+
<MultiSelectMenu
|
|
216
|
+
{...defaultProps()}
|
|
217
|
+
initialSelectedItems={[menuItems[0], menuItems[1]]}
|
|
218
|
+
onSelectedItemsChange={handleChange}
|
|
219
|
+
isOpen
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Wait for the popout to finish opening
|
|
224
|
+
await waitFor(() =>
|
|
225
|
+
expect(
|
|
226
|
+
screen.getAllByDataQaLabel({
|
|
227
|
+
"popout-isopen": "true",
|
|
228
|
+
}).length
|
|
229
|
+
).toBe(1)
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Click on the first token to remove it
|
|
233
|
+
const token = screen.getByRole("button", {
|
|
234
|
+
name: menuItems[0].label,
|
|
235
|
+
});
|
|
236
|
+
await act(() => user.click(token));
|
|
237
|
+
|
|
238
|
+
// The menu trigger is also a button so can't be queried by the role
|
|
239
|
+
const selectedItems = screen.getAllByDataQaLabel({
|
|
240
|
+
"token-isdisabled": "false",
|
|
241
|
+
});
|
|
242
|
+
expect(selectedItems.length).toBe(1);
|
|
243
|
+
expect(selectedItems[0]).toHaveTextContent(menuItems[1].label);
|
|
244
|
+
await waitFor(() => expect(handleChange).toHaveBeenCalled());
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { MenuSearchTokenInput } from "../MenuSearchTokenInput";
|
|
2
|
+
|
|
3
|
+
export const MenuSearchTokenInputTypeTest = () => (
|
|
4
|
+
<>
|
|
5
|
+
{/* Valid with required props */}
|
|
6
|
+
<MenuSearchTokenInput id="id" name="name" />
|
|
7
|
+
{/* Valid with optional input props */}
|
|
8
|
+
<MenuSearchTokenInput
|
|
9
|
+
id="id"
|
|
10
|
+
name="name"
|
|
11
|
+
aria-label="Search"
|
|
12
|
+
value=""
|
|
13
|
+
onChange={() => {}}
|
|
14
|
+
/>
|
|
15
|
+
{/* Valid with optional getIsItemVisible and getTokenProps prop */}
|
|
16
|
+
<MenuSearchTokenInput
|
|
17
|
+
id="id"
|
|
18
|
+
name="name"
|
|
19
|
+
getIsItemVisible={(item, inputValue) => item.id.includes(inputValue)}
|
|
20
|
+
getTokenProps={(itemId) => ({})}
|
|
21
|
+
/>
|
|
22
|
+
<MenuSearchTokenInput
|
|
23
|
+
id="id"
|
|
24
|
+
name="name"
|
|
25
|
+
/* @ts-expect-error - getIsItemVisible must be a valid function */
|
|
26
|
+
getIsItemVisible=""
|
|
27
|
+
/* @ts-expect-error - getTokenProps must be a valid function */
|
|
28
|
+
getTokenProps=""
|
|
29
|
+
/* @ts-expect-error - onChange must be a valid function */
|
|
30
|
+
onChange=""
|
|
31
|
+
/>
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./MenuSearchTokenInput";
|