@sproutsocial/seeds-react-menu 0.4.1 → 0.6.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 (105) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +32 -0
  3. package/dist/esm/index.js +1553 -423
  4. package/dist/esm/index.js.map +1 -1
  5. package/dist/index.d.mts +303 -122
  6. package/dist/index.d.ts +303 -122
  7. package/dist/index.js +1561 -405
  8. package/dist/index.js.map +1 -1
  9. package/package.json +4 -1
  10. package/src/ActionMenu/ActionMenu.stories.tsx +426 -0
  11. package/src/ActionMenu/ActionMenu.tsx +30 -26
  12. package/src/ActionMenu/ActionMenuTypes.ts +13 -8
  13. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +17 -30
  14. package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +32 -3
  15. package/src/Autocomplete/Autocomplete.stories.tsx +61 -2
  16. package/src/Autocomplete/Autocomplete.tsx +15 -74
  17. package/src/Autocomplete/AutocompleteInput.tsx +18 -27
  18. package/src/Autocomplete/AutocompleteTokenInput.tsx +50 -0
  19. package/src/Autocomplete/AutocompleteTypes.ts +27 -11
  20. package/src/Autocomplete/MultiAutocomplete.tsx +189 -0
  21. package/src/Autocomplete/SingleAutocomplete.tsx +74 -0
  22. package/src/Autocomplete/__tests__/Autocomplete.test.tsx +171 -3
  23. package/src/Autocomplete/__tests__/Autocomplete.typetest.tsx +116 -3
  24. package/src/MenuContent/MenuContent.stories.tsx +164 -0
  25. package/src/MenuContent/MenuContent.tsx +35 -8
  26. package/src/MenuContent/MenuContentTypes.ts +7 -5
  27. package/src/MenuContent/__tests__/MenuContent.typetest.tsx +18 -2
  28. package/src/MenuContext.tsx +191 -37
  29. package/src/MenuFooter/MenuFooter.stories.tsx +202 -0
  30. package/src/MenuFooter/__tests__/MenuFooter.test.tsx +16 -11
  31. package/src/MenuGroup/MenuGroup.feature +21 -21
  32. package/src/MenuGroup/MenuGroup.stories.tsx +248 -0
  33. package/src/MenuGroup/MenuGroup.tsx +9 -7
  34. package/src/MenuGroup/MenuGroupTypes.ts +8 -6
  35. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +6 -6
  36. package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +23 -4
  37. package/src/MenuGroup/styles.tsx +4 -2
  38. package/src/MenuHeader/MenuHeader.stories.tsx +276 -0
  39. package/src/MenuHeader/MenuHeader.tsx +3 -1
  40. package/src/MenuHeader/__tests__/MenuHeader.test.tsx +23 -13
  41. package/src/MenuHeader/styles.tsx +18 -14
  42. package/src/MenuItem/MenuItem.stories.tsx +507 -0
  43. package/src/MenuItem/MenuItem.tsx +18 -9
  44. package/src/MenuItem/MenuItemTypes.ts +9 -4
  45. package/src/MenuItem/__tests__/MenuItem.test.tsx +6 -6
  46. package/src/MenuItem/styles.tsx +21 -5
  47. package/src/MenuItem/useOptionProps.tsx +10 -8
  48. package/src/MenuLabel.tsx +2 -2
  49. package/src/MenuRoot/MenuRoot.tsx +37 -23
  50. package/src/MenuRoot/__tests__/MenuRoot.test.tsx +59 -32
  51. package/src/MenuSearchInput/MenuSearchInput.tsx +63 -0
  52. package/src/MenuSearchInput/__tests__/MenuSearchInput.test.tsx +157 -0
  53. package/src/MenuSearchInput/__tests__/MenuSearchInput.typetest.tsx +31 -0
  54. package/src/MenuSearchInput/index.ts +1 -0
  55. package/src/MenuSearchTokenInput/MenuSearchTokenInput.tsx +98 -0
  56. package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.test.tsx +247 -0
  57. package/src/MenuSearchTokenInput/__tests__/MenuSearchTokenInput.typetest.tsx +33 -0
  58. package/src/MenuSearchTokenInput/index.ts +1 -0
  59. package/src/MenuToggleButton/MenuToggleButton.stories.tsx +72 -0
  60. package/src/{MenuToggleButton.tsx → MenuToggleButton/MenuToggleButton.tsx} +6 -5
  61. package/src/MenuToggleButton/index.ts +1 -0
  62. package/src/MenuTokenInput.tsx +104 -0
  63. package/src/MultiSelectMenu/MultiSelectMenu.stories.tsx +214 -0
  64. package/src/MultiSelectMenu/MultiSelectMenu.tsx +44 -67
  65. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +9 -5
  66. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +46 -60
  67. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +29 -2
  68. package/src/NestedMenu/NestedMenu.feature +109 -0
  69. package/src/NestedMenu/NestedMenu.stories.tsx +157 -0
  70. package/src/NestedMenu/NestedMenu.tsx +102 -0
  71. package/src/NestedMenu/NestedMenuContent.tsx +71 -0
  72. package/src/NestedMenu/NestedMenuContext.tsx +56 -0
  73. package/src/NestedMenu/NestedMenuHeader.tsx +25 -0
  74. package/src/NestedMenu/NestedMenuItem.tsx +37 -0
  75. package/src/NestedMenu/NestedMenuTypes.ts +47 -0
  76. package/src/NestedMenu/__tests__/NestedMenu.test.tsx +31 -0
  77. package/src/NestedMenu/__tests__/NestedMenu.typetest.tsx +134 -0
  78. package/src/NestedMenu/index.ts +5 -0
  79. package/src/SingleSelectMenu/SingleSelectMenu.stories.tsx +227 -0
  80. package/src/SingleSelectMenu/SingleSelectMenu.tsx +29 -25
  81. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +9 -5
  82. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +32 -2
  83. package/src/SubmenuItem/SubmenuItem.feature +82 -0
  84. package/src/SubmenuItem/SubmenuItem.stories.tsx +163 -0
  85. package/src/SubmenuItem/SubmenuItem.tsx +254 -0
  86. package/src/SubmenuItem/SubmenuItemTypes.ts +24 -0
  87. package/src/SubmenuItem/__tests__/SubmenuItem.test.tsx +3 -0
  88. package/src/SubmenuItem/__tests__/SubmenuItem.typetest.tsx +112 -0
  89. package/src/SubmenuItem/index.ts +2 -0
  90. package/src/__tests__/Menu.test.tsx +38 -0
  91. package/src/hooks/useDownshiftDefaults.tsx +107 -0
  92. package/src/hooks/useItemFiltering.tsx +66 -0
  93. package/src/hooks/useMenuChildren.tsx +279 -0
  94. package/src/index.ts +7 -1
  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 -5
  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/hooks/useItemsFromChildren.tsx +0 -131
  105. package/src/utils/itemToString.ts +0 -5
@@ -0,0 +1,63 @@
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 { useMenuContentContext } from "../MenuContext";
8
+
9
+ export interface TypeMenuSearchInputProps extends TypeInputProps {
10
+ /* A function that can be passed to override the default filtering logic */
11
+ getIsItemVisible?: TypeGetIsItemVisibleFn;
12
+ }
13
+
14
+ /**
15
+ * @link https://seeds.sproutsocial.com/components/menu-header/
16
+ *
17
+ * @description MenuSearchInput is search input that can be added to the MenuHeader to filter the items.
18
+ */
19
+
20
+ export const MenuSearchInput = ({
21
+ getIsItemVisible,
22
+ ...inputProps
23
+ }: TypeMenuSearchInputProps) => {
24
+ const {
25
+ items,
26
+ itemToString,
27
+ setHiddenItemsMap,
28
+ isOpen,
29
+ getToggleButtonProps,
30
+ } = useMenuContentContext();
31
+ const { updateFilteredItems } = useItemFiltering({
32
+ allMenuItems: items,
33
+ itemToString,
34
+ getIsItemVisible,
35
+ setHiddenItemsMap,
36
+ });
37
+
38
+ React.useEffect(() => {
39
+ // handles when open/close state changes or if input is controlled
40
+ updateFilteredItems({ inputValue: inputProps.value || "" });
41
+ }, [isOpen, inputProps.value]);
42
+
43
+ const handleKeyDown = React.useCallback(
44
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
45
+ // prevents selecting items with space key when input is focused
46
+ if (e.key === " ") return;
47
+ getToggleButtonProps?.()["onKeyDown"](e);
48
+ },
49
+ [getToggleButtonProps]
50
+ );
51
+
52
+ return (
53
+ <Input
54
+ onChange={(e, value) => {
55
+ updateFilteredItems({ inputValue: value });
56
+ }}
57
+ // apply onKeyDown logic for downshift keyboard accessibility support
58
+ onKeyDown={handleKeyDown}
59
+ autoComplete={false}
60
+ {...inputProps}
61
+ />
62
+ );
63
+ };
@@ -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,98 @@
1
+ import React from "react";
2
+ import {
3
+ useItemFiltering,
4
+ type TypeGetIsItemVisibleFn,
5
+ } from "../hooks/useItemFiltering";
6
+ import { MenuContentContext, useMenuContentContext } from "../MenuContext";
7
+ import {
8
+ MenuTokenInput,
9
+ type TypeMenuTokenInputProps,
10
+ } from "../MenuTokenInput";
11
+
12
+ export interface TypeMenuSearchTokenInputProps extends TypeMenuTokenInputProps {
13
+ /* A function that can be passed to override the default filtering logic */
14
+ getIsItemVisible?: TypeGetIsItemVisibleFn;
15
+ }
16
+
17
+ /**
18
+ * @link https://seeds.sproutsocial.com/components/menu-search-token-input/
19
+ *
20
+ * @description MenuSearchTokenInput is search token input that can be added to the MenuHeader to filter the items.
21
+ */
22
+ export const MenuSearchTokenInput = ({
23
+ getIsItemVisible,
24
+ getTokenProps,
25
+ onChange,
26
+ inputProps: { value: inputPropsValue, ...inputProps } = {},
27
+ value,
28
+ ...tokenInputProps
29
+ }: TypeMenuSearchTokenInputProps) => {
30
+ const {
31
+ items,
32
+ itemToString,
33
+ setHiddenItemsMap,
34
+ isOpen,
35
+ getToggleButtonProps,
36
+ } = useMenuContentContext();
37
+ const { updateFilteredItems } = useItemFiltering({
38
+ allMenuItems: items,
39
+ itemToString,
40
+ getIsItemVisible,
41
+ setHiddenItemsMap,
42
+ });
43
+
44
+ // set controlled value
45
+ const controlledValue =
46
+ value || inputPropsValue !== undefined ? `${inputPropsValue}` : undefined;
47
+
48
+ // internal state helps to keep track of the input value since it sometimes resets on its own
49
+ const [internalValue, setInternalValue] = React.useState(
50
+ controlledValue || ""
51
+ );
52
+
53
+ // any time state is updated, also update the filtered items
54
+ const updateFilteredState = React.useCallback(
55
+ // defaults to an empty string if no value is passed
56
+ (value: string = "") => {
57
+ setInternalValue(value);
58
+ updateFilteredItems({ inputValue: value });
59
+ },
60
+ [updateFilteredItems]
61
+ );
62
+
63
+ React.useEffect(() => {
64
+ // handles when input is controlled to keep internal state updated
65
+ updateFilteredState(controlledValue);
66
+ }, [controlledValue]);
67
+
68
+ React.useEffect(() => {
69
+ // handles when open/close state changes
70
+ updateFilteredState(controlledValue);
71
+ }, [isOpen]);
72
+
73
+ const handleKeyDown = React.useCallback(
74
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
75
+ // prevents selecting items with space key when input is focused
76
+ if (e.key === " ") return;
77
+ getToggleButtonProps?.()["onKeyDown"](e);
78
+ },
79
+ [getToggleButtonProps]
80
+ );
81
+
82
+ return (
83
+ <MenuTokenInput
84
+ onChange={(e, value) => {
85
+ updateFilteredState(value);
86
+ onChange?.(e, value);
87
+ }}
88
+ // apply onKeyDown logic for downshift keyboard accessibility support
89
+ onKeyDown={handleKeyDown}
90
+ inputProps={{
91
+ value: internalValue,
92
+ ...inputProps,
93
+ }}
94
+ MenuContext={MenuContentContext}
95
+ {...tokenInputProps}
96
+ />
97
+ );
98
+ };
@@ -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";