@sproutsocial/seeds-react-menu 0.1.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/.eslintrc.js +4 -0
- package/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +46 -0
- package/dist/esm/index.js +2651 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.mts +197 -0
- package/dist/index.d.ts +197 -0
- package/dist/index.js +2670 -0
- package/dist/index.js.map +1 -0
- package/jest.config.js +12 -0
- package/package.json +50 -0
- package/src/ActionMenu/ActionMenu.feature +20 -0
- package/src/ActionMenu/ActionMenu.tsx +113 -0
- package/src/ActionMenu/__tests__/ActionMenu.test.tsx +99 -0
- package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +73 -0
- package/src/ActionMenu/index.ts +1 -0
- package/src/ComboBox/ComboBox.feature +96 -0
- package/src/ComboBox/TokenInput.feature +84 -0
- package/src/MenuContent/MenuContent.tsx +28 -0
- package/src/MenuContent/MenuContentTypes.ts +7 -0
- package/src/MenuContent/__tests__/MenuContent.test.tsx +59 -0
- package/src/MenuContent/__tests__/MenuContent.typetest.tsx +18 -0
- package/src/MenuContent/index.tsx +2 -0
- package/src/MenuContent/styles.tsx +10 -0
- package/src/MenuContext.tsx +116 -0
- package/src/MenuFooter/MenuFooter.feature +30 -0
- package/src/MenuFooter/MenuFooter.tsx +7 -0
- package/src/MenuFooter/MenuFooterTypes.ts +1 -0
- package/src/MenuFooter/__tests__/MenuFooter.test.tsx +76 -0
- package/src/MenuFooter/__tests__/MenuFooter.typetest.tsx +20 -0
- package/src/MenuFooter/index.ts +2 -0
- package/src/MenuFooter/styles.tsx +7 -0
- package/src/MenuGroup/MenuGroup.feature +114 -0
- package/src/MenuGroup/MenuGroup.tsx +33 -0
- package/src/MenuGroup/MenuGroupTypes.ts +29 -0
- package/src/MenuGroup/__tests__/MenuGroup.test.tsx +95 -0
- package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +45 -0
- package/src/MenuGroup/index.tsx +8 -0
- package/src/MenuGroup/styles.tsx +37 -0
- package/src/MenuHeader/MenuHeader.feature +59 -0
- package/src/MenuHeader/MenuHeader.tsx +30 -0
- package/src/MenuHeader/MenuHeaderTypes.ts +26 -0
- package/src/MenuHeader/__tests__/MenuHeader.test.tsx +67 -0
- package/src/MenuHeader/__tests__/MenuHeader.typetest.tsx +27 -0
- package/src/MenuHeader/index.ts +2 -0
- package/src/MenuHeader/styles.tsx +75 -0
- package/src/MenuItem/MenuItem.feature +88 -0
- package/src/MenuItem/MenuItem.tsx +44 -0
- package/src/MenuItem/MenuItemTypes.ts +37 -0
- package/src/MenuItem/__tests__/MenuItem.test.tsx +228 -0
- package/src/MenuItem/__tests__/MenuItem.typetest.tsx +41 -0
- package/src/MenuItem/index.ts +5 -0
- package/src/MenuItem/styles.tsx +71 -0
- package/src/MenuItemSelectable/MenuItemSelectable.feature +99 -0
- package/src/MenuItemSelectable/MenuItemSelectable.tsx +136 -0
- package/src/MenuItemSelectable/MenuItemSelectableTypes.ts +15 -0
- package/src/MenuItemSelectable/__tests__/MenuItemSelectable.test.tsx +303 -0
- package/src/MenuItemSelectable/__tests__/MenuItemSelectable.typetest.tsx +56 -0
- package/src/MenuItemSelectable/index.ts +5 -0
- package/src/MenuItemSelectable/styles.tsx +21 -0
- package/src/MenuLabel.tsx +12 -0
- package/src/MenuRoot/MenuRoot.feature +93 -0
- package/src/MenuRoot/MenuRoot.tsx +70 -0
- package/src/MenuRoot/__tests__/MenuRoot.test.tsx +197 -0
- package/src/MenuRoot/__tests__/MenuRoot.typetest.tsx +76 -0
- package/src/MenuRoot/index.ts +1 -0
- package/src/MenuToggleButton.tsx +29 -0
- package/src/MultiSelectMenu/MultiSelectMenu.feature +70 -0
- package/src/MultiSelectMenu/MultiSelectMenu.tsx +133 -0
- package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +8 -0
- package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +349 -0
- package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +78 -0
- package/src/MultiSelectMenu/index.ts +1 -0
- package/src/SingleSelectMenu/SingleSelectMenu.feature +23 -0
- package/src/SingleSelectMenu/SingleSelectMenu.tsx +56 -0
- package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +9 -0
- package/src/SingleSelectMenu/__tests__/SingleSelectMenu.test.tsx +167 -0
- package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +95 -0
- package/src/SingleSelectMenu/index.ts +3 -0
- package/src/__tests__/Menu.test.tsx +423 -0
- package/src/constants.ts +17 -0
- package/src/hooks/useItemsFromChildren.tsx +81 -0
- package/src/index.ts +20 -0
- package/src/menuTestingUtils.tsx +72 -0
- package/src/reducers/__tests__/useSelectStateReducer.test.tsx +37 -0
- package/src/reducers/useSelectStateReducer.tsx +27 -0
- package/src/styled.d.ts +7 -0
- package/src/types.ts +16 -0
- package/src/utils/itemToString.ts +5 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +11 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
useSelect,
|
|
4
|
+
useMultipleSelection,
|
|
5
|
+
type UseSelectState,
|
|
6
|
+
} from "downshift";
|
|
7
|
+
import { useItemsFromChildren } from "../hooks/useItemsFromChildren";
|
|
8
|
+
import {
|
|
9
|
+
defaultUseSelectProps,
|
|
10
|
+
useSelectStateReducer,
|
|
11
|
+
} from "../reducers/useSelectStateReducer";
|
|
12
|
+
import { itemToString } from "../utils/itemToString";
|
|
13
|
+
import { MenuRoot } from "../MenuRoot";
|
|
14
|
+
import type { TypeMultiSelectMenuProps } from "./MultiSelectMenuTypes";
|
|
15
|
+
import type { TypeDefaultItemProps } from "../types";
|
|
16
|
+
|
|
17
|
+
const selectReducerForMultiSelect: TypeMultiSelectMenuProps["stateReducer"] = (
|
|
18
|
+
state,
|
|
19
|
+
actionAndChanges
|
|
20
|
+
) => {
|
|
21
|
+
const { changes, type } = actionAndChanges;
|
|
22
|
+
switch (type) {
|
|
23
|
+
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
|
|
24
|
+
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
|
|
25
|
+
case useSelect.stateChangeTypes.ItemClick:
|
|
26
|
+
return {
|
|
27
|
+
...changes,
|
|
28
|
+
isOpen: true, // keep the menu open after selection.
|
|
29
|
+
// TODO: Confirm that keeping highlightedIndex the same is the desired UX
|
|
30
|
+
// highlightedIndex: 0, // with the first option highlighted.
|
|
31
|
+
highlightedIndex: state.highlightedIndex, // keep highlightedIndex the same instead of returning to 0
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return changes;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const MultiSelectMenu = ({
|
|
38
|
+
children,
|
|
39
|
+
stateReducer: externalStateReducer,
|
|
40
|
+
getA11yMultiStatusMessage,
|
|
41
|
+
onMultiSelectStateChange,
|
|
42
|
+
multiSelectStateReducer,
|
|
43
|
+
...useSelectProps
|
|
44
|
+
}: TypeMultiSelectMenuProps) => {
|
|
45
|
+
const { allMenuItems, menuItemsMap } = useItemsFromChildren({ children });
|
|
46
|
+
|
|
47
|
+
const multiSelectOverrides = Object.entries({
|
|
48
|
+
...useSelectProps,
|
|
49
|
+
getA11yStatusMessage: getA11yMultiStatusMessage,
|
|
50
|
+
onStateChange: onMultiSelectStateChange,
|
|
51
|
+
stateReducer: multiSelectStateReducer,
|
|
52
|
+
}).reduce(
|
|
53
|
+
(a, [k, v]) => (v ? ((a[k] = v), a) : a),
|
|
54
|
+
{} as {
|
|
55
|
+
getA11yStatusMessage?: TypeMultiSelectMenuProps["getA11yMultiStatusMessage"];
|
|
56
|
+
onStateChange?: TypeMultiSelectMenuProps["onMultiSelectStateChange"];
|
|
57
|
+
stateReducer?: TypeMultiSelectMenuProps["multiSelectStateReducer"];
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
const {
|
|
61
|
+
getSelectedItemProps,
|
|
62
|
+
addSelectedItem,
|
|
63
|
+
removeSelectedItem,
|
|
64
|
+
selectedItems,
|
|
65
|
+
getDropdownProps,
|
|
66
|
+
...useMultipleSelectionReturnProps
|
|
67
|
+
} = useMultipleSelection<TypeDefaultItemProps>(multiSelectOverrides);
|
|
68
|
+
|
|
69
|
+
const { isOpen, ...useSelectReturnProps } = useSelect({
|
|
70
|
+
...defaultUseSelectProps,
|
|
71
|
+
items: allMenuItems,
|
|
72
|
+
itemToString,
|
|
73
|
+
selectedItem: null,
|
|
74
|
+
defaultHighlightedIndex: 0,
|
|
75
|
+
stateReducer: (state, actionAndChanges) => {
|
|
76
|
+
let newState: Partial<UseSelectState<TypeDefaultItemProps>>;
|
|
77
|
+
newState = useSelectStateReducer(state, actionAndChanges);
|
|
78
|
+
newState = selectReducerForMultiSelect(state, {
|
|
79
|
+
...actionAndChanges,
|
|
80
|
+
changes: newState,
|
|
81
|
+
});
|
|
82
|
+
return externalStateReducer
|
|
83
|
+
? externalStateReducer(state, {
|
|
84
|
+
...actionAndChanges,
|
|
85
|
+
changes: newState,
|
|
86
|
+
})
|
|
87
|
+
: newState;
|
|
88
|
+
},
|
|
89
|
+
onStateChange: ({ type, selectedItem: newSelectedItem }) => {
|
|
90
|
+
switch (type) {
|
|
91
|
+
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
|
|
92
|
+
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
|
|
93
|
+
case useSelect.stateChangeTypes.ItemClick:
|
|
94
|
+
case useSelect.stateChangeTypes.ToggleButtonBlur:
|
|
95
|
+
if (!newSelectedItem) break;
|
|
96
|
+
if (selectedItems?.find((item) => item?.id === newSelectedItem.id)) {
|
|
97
|
+
removeSelectedItem(newSelectedItem);
|
|
98
|
+
} else {
|
|
99
|
+
addSelectedItem(newSelectedItem);
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
default:
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
...useSelectProps,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<MenuRoot
|
|
111
|
+
{...{
|
|
112
|
+
isListbox: true,
|
|
113
|
+
items: allMenuItems,
|
|
114
|
+
itemsMap: menuItemsMap,
|
|
115
|
+
itemToString,
|
|
116
|
+
isOpen,
|
|
117
|
+
getSelectedItemProps,
|
|
118
|
+
addSelectedItem,
|
|
119
|
+
removeSelectedItem,
|
|
120
|
+
selectedItems,
|
|
121
|
+
getDropdownProps,
|
|
122
|
+
getA11yMultiStatusMessage,
|
|
123
|
+
onMultiSelectStateChange,
|
|
124
|
+
multiSelectStateReducer,
|
|
125
|
+
...useSelectProps,
|
|
126
|
+
...useSelectReturnProps,
|
|
127
|
+
...useMultipleSelectionReturnProps,
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{children}
|
|
131
|
+
</MenuRoot>
|
|
132
|
+
);
|
|
133
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TypeMenuRootProps, TypeMenuRootOwnProps } from "../MenuRoot";
|
|
2
|
+
import type { TypeDownshiftMultiSelectProps } from "../MenuContext";
|
|
3
|
+
|
|
4
|
+
export interface TypeMultiSelectMenuProps
|
|
5
|
+
extends TypeMenuRootOwnProps,
|
|
6
|
+
TypeDownshiftMultiSelectProps {
|
|
7
|
+
children: TypeMenuRootProps["children"];
|
|
8
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/* eslint-disable testing-library/no-unnecessary-act */
|
|
2
|
+
import {
|
|
3
|
+
MultiSelectMenu,
|
|
4
|
+
MenuContent,
|
|
5
|
+
MenuToggleButton,
|
|
6
|
+
MenuItemSelectable,
|
|
7
|
+
MenuGroup,
|
|
8
|
+
} from "../../index";
|
|
9
|
+
import {
|
|
10
|
+
render,
|
|
11
|
+
screen,
|
|
12
|
+
fireEvent,
|
|
13
|
+
act,
|
|
14
|
+
cleanup,
|
|
15
|
+
waitFor,
|
|
16
|
+
} from "@sproutsocial/seeds-react-testing-library";
|
|
17
|
+
|
|
18
|
+
const menuItems = [
|
|
19
|
+
{ id: "test-item-1", index: 0, label: "Item 1" },
|
|
20
|
+
{ id: "test-item-2", index: 1, label: "Item 2" },
|
|
21
|
+
{ id: "test-item-3", index: 2, label: "Item 3" },
|
|
22
|
+
{ id: "test-item-4", index: 3, label: "Item 4" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe("MultiSelectMenu", () => {
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
cleanup();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should render the menuItemSelectables when provided as children", async () => {
|
|
31
|
+
const { user } = render(
|
|
32
|
+
<MultiSelectMenu
|
|
33
|
+
triggerElement={
|
|
34
|
+
<MenuToggleButton aria-label="testable menu" role="button">
|
|
35
|
+
Testable Menu
|
|
36
|
+
</MenuToggleButton>
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
<MenuContent>
|
|
40
|
+
<MenuGroup>
|
|
41
|
+
{menuItems.map((item) => (
|
|
42
|
+
<MenuItemSelectable key={item.id} id={item.id} label={item.label}>
|
|
43
|
+
{item.label}
|
|
44
|
+
</MenuItemSelectable>
|
|
45
|
+
))}
|
|
46
|
+
</MenuGroup>
|
|
47
|
+
</MenuContent>
|
|
48
|
+
</MultiSelectMenu>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Check that the menu is closed
|
|
52
|
+
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
53
|
+
|
|
54
|
+
const toggleButton = screen.getByRole("button", { name: "testable menu" });
|
|
55
|
+
|
|
56
|
+
// Open Menu
|
|
57
|
+
await user.click(toggleButton);
|
|
58
|
+
|
|
59
|
+
await waitFor(() => {
|
|
60
|
+
// Check that the menu is open
|
|
61
|
+
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
|
62
|
+
// Check that the menu items are present
|
|
63
|
+
expect(
|
|
64
|
+
screen.getByRole("option", { name: "Item 1" })
|
|
65
|
+
).toBeInTheDocument();
|
|
66
|
+
expect(
|
|
67
|
+
// Check that the menu items are present
|
|
68
|
+
screen.getByRole("option", { name: "Item 2" })
|
|
69
|
+
).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
xit("should allow for multiple items to be selected", async () => {
|
|
74
|
+
const { user } = render(
|
|
75
|
+
<MultiSelectMenu
|
|
76
|
+
triggerElement={
|
|
77
|
+
<MenuToggleButton aria-label="testable menu" role="button">
|
|
78
|
+
Testable Menu
|
|
79
|
+
</MenuToggleButton>
|
|
80
|
+
}
|
|
81
|
+
>
|
|
82
|
+
<MenuContent>
|
|
83
|
+
<MenuGroup>
|
|
84
|
+
{menuItems.map((item) => (
|
|
85
|
+
<MenuItemSelectable
|
|
86
|
+
key={item.id}
|
|
87
|
+
id={item.id}
|
|
88
|
+
data-testid={item.id}
|
|
89
|
+
label={item.label}
|
|
90
|
+
>
|
|
91
|
+
{item.label}
|
|
92
|
+
</MenuItemSelectable>
|
|
93
|
+
))}
|
|
94
|
+
</MenuGroup>
|
|
95
|
+
</MenuContent>
|
|
96
|
+
</MultiSelectMenu>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const toggleButton = screen.getByRole("button", { name: "testable menu" });
|
|
100
|
+
|
|
101
|
+
await user.click(toggleButton);
|
|
102
|
+
|
|
103
|
+
const item1 = screen.getByRole("option", { name: "Item 1" });
|
|
104
|
+
const item2 = screen.getByRole("option", { name: "Item 2" });
|
|
105
|
+
|
|
106
|
+
// For some reason the pointer-events are not being set to none on render. This essentially waits for the pointer-events to be set to none before getting to the actual tests.
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(item1).not.toHaveStyle("pointer-events: none");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Select first item
|
|
112
|
+
await user.click(item1);
|
|
113
|
+
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
// Check that the first item is selected
|
|
116
|
+
expect(item1).toHaveAttribute("aria-checked", "true");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Select second item
|
|
120
|
+
// TODO: can this be done in the same act as the first item?
|
|
121
|
+
await user.click(item2);
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
// Check that the second item is selected
|
|
125
|
+
expect(item2).toHaveAttribute("aria-checked", "true");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
xit("Should maintain selected state when closed and reopened", async () => {
|
|
130
|
+
const { user } = render(
|
|
131
|
+
<MultiSelectMenu
|
|
132
|
+
triggerElement={
|
|
133
|
+
<MenuToggleButton aria-label="testable menu" role="button">
|
|
134
|
+
Testable Menu
|
|
135
|
+
</MenuToggleButton>
|
|
136
|
+
}
|
|
137
|
+
>
|
|
138
|
+
<MenuContent>
|
|
139
|
+
<MenuGroup>
|
|
140
|
+
{menuItems.map((item) => (
|
|
141
|
+
<MenuItemSelectable
|
|
142
|
+
key={item.id}
|
|
143
|
+
id={item.id}
|
|
144
|
+
data-testid={item.id}
|
|
145
|
+
label={item.label}
|
|
146
|
+
>
|
|
147
|
+
{item.label}
|
|
148
|
+
</MenuItemSelectable>
|
|
149
|
+
))}
|
|
150
|
+
</MenuGroup>
|
|
151
|
+
</MenuContent>
|
|
152
|
+
</MultiSelectMenu>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const toggleButton = screen.getByRole("button", { name: "testable menu" });
|
|
156
|
+
|
|
157
|
+
// Open Menu
|
|
158
|
+
await user.click(toggleButton);
|
|
159
|
+
|
|
160
|
+
const item1 = screen.getByRole("option", { name: "Item 1" });
|
|
161
|
+
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(item1).not.toHaveStyle("pointer-events: none");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Select Item 1
|
|
167
|
+
await user.click(item1);
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(item1).toHaveAttribute("aria-checked", "true");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Close Menu (MultiSelect does not close automatically)
|
|
174
|
+
await user.click(toggleButton);
|
|
175
|
+
|
|
176
|
+
// Make sure the menu is closed
|
|
177
|
+
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
178
|
+
|
|
179
|
+
// Open menu again
|
|
180
|
+
await user.click(toggleButton);
|
|
181
|
+
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
// Make sure the menu is open
|
|
184
|
+
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
|
185
|
+
// Make sure the item is still selected
|
|
186
|
+
expect(item1).toHaveAttribute("aria-checked", "true");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
xit("should be able to navigate using the keyboard", async () => {
|
|
191
|
+
const { user } = render(
|
|
192
|
+
<MultiSelectMenu
|
|
193
|
+
triggerElement={
|
|
194
|
+
<MenuToggleButton aria-label="testable menu" role="button">
|
|
195
|
+
Testable Menu
|
|
196
|
+
</MenuToggleButton>
|
|
197
|
+
}
|
|
198
|
+
>
|
|
199
|
+
<MenuContent>
|
|
200
|
+
<MenuGroup>
|
|
201
|
+
{menuItems.map((item) => (
|
|
202
|
+
<MenuItemSelectable
|
|
203
|
+
key={item.id}
|
|
204
|
+
id={item.id}
|
|
205
|
+
data-testid={item.id}
|
|
206
|
+
label={item.label}
|
|
207
|
+
>
|
|
208
|
+
{item.label}
|
|
209
|
+
</MenuItemSelectable>
|
|
210
|
+
))}
|
|
211
|
+
</MenuGroup>
|
|
212
|
+
</MenuContent>
|
|
213
|
+
</MultiSelectMenu>
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const toggleButton = screen.getByRole("button", { name: "testable menu" });
|
|
217
|
+
|
|
218
|
+
// Open Menu;
|
|
219
|
+
fireEvent.focus(toggleButton);
|
|
220
|
+
await user.keyboard("{Enter}");
|
|
221
|
+
await user.keyboard("{ArrowDown}");
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
// Check that the first item is highlighted
|
|
225
|
+
const item1 = screen.getByRole("option", { name: "Item 1" });
|
|
226
|
+
expect(item1).toHaveFocus();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Select the first item
|
|
230
|
+
await user.keyboard("{Enter}");
|
|
231
|
+
await user.keyboard("{ArrowDown}");
|
|
232
|
+
// select the second item
|
|
233
|
+
await user.keyboard(" ");
|
|
234
|
+
|
|
235
|
+
await waitFor(() => {
|
|
236
|
+
// Check that the second item is focused and selected
|
|
237
|
+
const item2 = screen.getByRole("option", { name: "Item 2" });
|
|
238
|
+
expect(item2).toHaveFocus();
|
|
239
|
+
expect(item2).toBeChecked();
|
|
240
|
+
|
|
241
|
+
// Check that the first item is still selected
|
|
242
|
+
const item1 = screen.getByRole("menuitem", { name: "Item 1" });
|
|
243
|
+
expect(item1).toBeChecked();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
xit("should stay open after menu item selection", async () => {
|
|
247
|
+
const { user } = render(
|
|
248
|
+
<MultiSelectMenu
|
|
249
|
+
triggerElement={
|
|
250
|
+
<MenuToggleButton aria-label="testable menu" role="button">
|
|
251
|
+
Testable Menu
|
|
252
|
+
</MenuToggleButton>
|
|
253
|
+
}
|
|
254
|
+
>
|
|
255
|
+
<MenuContent>
|
|
256
|
+
<MenuGroup>
|
|
257
|
+
{menuItems.map((item) => (
|
|
258
|
+
<MenuItemSelectable
|
|
259
|
+
key={item.id}
|
|
260
|
+
id={item.id}
|
|
261
|
+
data-testid={item.id}
|
|
262
|
+
label={item.label}
|
|
263
|
+
>
|
|
264
|
+
{item.label}
|
|
265
|
+
</MenuItemSelectable>
|
|
266
|
+
))}
|
|
267
|
+
</MenuGroup>
|
|
268
|
+
</MenuContent>
|
|
269
|
+
</MultiSelectMenu>
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const menuContentContainer = screen.queryByRole("listbox");
|
|
273
|
+
|
|
274
|
+
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
275
|
+
|
|
276
|
+
const toggleButton = screen.getByRole("button", {
|
|
277
|
+
name: "testable menu",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Open Menu
|
|
281
|
+
await user.click(toggleButton);
|
|
282
|
+
|
|
283
|
+
const item1 = screen.getByRole("option", { name: "Item 1" });
|
|
284
|
+
|
|
285
|
+
await waitFor(() => {
|
|
286
|
+
expect(item1).not.toHaveStyle("pointer-events: none");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Select Item 1
|
|
290
|
+
await user.click(item1);
|
|
291
|
+
|
|
292
|
+
await waitFor(() => {
|
|
293
|
+
expect(menuContentContainer).toBeInTheDocument();
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
xit("Should work with a initially selected items", async () => {
|
|
298
|
+
const { user } = render(
|
|
299
|
+
<MultiSelectMenu
|
|
300
|
+
triggerElement={
|
|
301
|
+
<MenuToggleButton aria-label="testable menu" role="button">
|
|
302
|
+
Testable Menu
|
|
303
|
+
</MenuToggleButton>
|
|
304
|
+
}
|
|
305
|
+
defaultSelectedItems={[menuItems[1], menuItems[2]]}
|
|
306
|
+
>
|
|
307
|
+
<MenuContent>
|
|
308
|
+
<MenuGroup>
|
|
309
|
+
{menuItems.map((item) => (
|
|
310
|
+
<MenuItemSelectable
|
|
311
|
+
key={item.id}
|
|
312
|
+
id={item.id}
|
|
313
|
+
data-testid={item.id}
|
|
314
|
+
label={item.label}
|
|
315
|
+
>
|
|
316
|
+
{item.label}
|
|
317
|
+
</MenuItemSelectable>
|
|
318
|
+
))}
|
|
319
|
+
</MenuGroup>
|
|
320
|
+
</MenuContent>
|
|
321
|
+
</MultiSelectMenu>
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
|
325
|
+
|
|
326
|
+
const toggleButton = screen.getByRole("button", {
|
|
327
|
+
name: "testable menu",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Open Menu
|
|
331
|
+
await user.click(toggleButton);
|
|
332
|
+
|
|
333
|
+
const item1 = screen.getByRole("option", { name: "Item 1" });
|
|
334
|
+
const item2 = screen.getByRole("option", { name: "Item 2" });
|
|
335
|
+
const item3 = screen.getByRole("option", { name: "Item 3" });
|
|
336
|
+
|
|
337
|
+
await waitFor(() => {
|
|
338
|
+
expect(item1).not.toHaveStyle("pointer-events: none");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
act(() => {
|
|
342
|
+
// Check that the second item is selected
|
|
343
|
+
expect(item2).toHaveAttribute("aria-checked", "true");
|
|
344
|
+
// Check that the third item is selected
|
|
345
|
+
expect(item3).toHaveAttribute("aria-checked", "true");
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
MultiSelectMenu,
|
|
4
|
+
MenuContent,
|
|
5
|
+
MenuHeader,
|
|
6
|
+
MenuFooter,
|
|
7
|
+
} from "../../index";
|
|
8
|
+
|
|
9
|
+
export const MultiSelectMenuTypeTest = () => (
|
|
10
|
+
<>
|
|
11
|
+
{/* Valid MultiSelectMenu with minimal required props */}
|
|
12
|
+
<MultiSelectMenu triggerElement={<>test</>}>Test</MultiSelectMenu>
|
|
13
|
+
{/* Valid MultiSelectMenu allows MenuContent by itself as a child */}
|
|
14
|
+
<MultiSelectMenu triggerElement={<>test</>}>
|
|
15
|
+
<MenuContent>Test</MenuContent>
|
|
16
|
+
</MultiSelectMenu>
|
|
17
|
+
{/* Valid MultiSelectMenu allows multiple children without fragment */}
|
|
18
|
+
<MultiSelectMenu triggerElement={<>test</>}>
|
|
19
|
+
<MenuHeader>Test</MenuHeader>
|
|
20
|
+
<MenuContent>Test</MenuContent>
|
|
21
|
+
<MenuFooter>Test</MenuFooter>
|
|
22
|
+
</MultiSelectMenu>
|
|
23
|
+
{/* Valid MultiSelectMenu accepts Downshift props and functions for useSelect */}
|
|
24
|
+
<MultiSelectMenu
|
|
25
|
+
triggerElement={<>test</>}
|
|
26
|
+
onSelectedItemChange={() => {}}
|
|
27
|
+
selectedItem={null}
|
|
28
|
+
stateReducer={(c) => c}
|
|
29
|
+
onStateChange={() => {}}
|
|
30
|
+
getA11yStatusMessage={() => ""}
|
|
31
|
+
isOpen
|
|
32
|
+
>
|
|
33
|
+
Test
|
|
34
|
+
</MultiSelectMenu>
|
|
35
|
+
{/* Valid MultiSelectMenu accepts Downshift props and functions for useMultipleSelection */}
|
|
36
|
+
<MultiSelectMenu
|
|
37
|
+
triggerElement={<>test</>}
|
|
38
|
+
onSelectedItemsChange={() => {}}
|
|
39
|
+
selectedItems={[]}
|
|
40
|
+
multiSelectStateReducer={(c) => c}
|
|
41
|
+
onMultiSelectStateChange={() => {}}
|
|
42
|
+
getA11yMultiStatusMessage={() => ""}
|
|
43
|
+
>
|
|
44
|
+
Test
|
|
45
|
+
</MultiSelectMenu>
|
|
46
|
+
|
|
47
|
+
{/* @ts-expect-error - triggerElement is a required prop */}
|
|
48
|
+
<MultiSelectMenu>Test</MultiSelectMenu>
|
|
49
|
+
<MultiSelectMenu
|
|
50
|
+
/* @ts-expect-error - triggerElement must be a valid react element */
|
|
51
|
+
triggerElement="test"
|
|
52
|
+
>
|
|
53
|
+
Test
|
|
54
|
+
</MultiSelectMenu>
|
|
55
|
+
{/* @ts-expect-error - when no children provided */}
|
|
56
|
+
<MultiSelectMenu triggerElement="test" />
|
|
57
|
+
|
|
58
|
+
<MultiSelectMenu
|
|
59
|
+
triggerElement={<>test</>}
|
|
60
|
+
/* @ts-expect-error - isOpen can't be a string */
|
|
61
|
+
isOpen="false"
|
|
62
|
+
/* @ts-expect-error - onSelectedItemChange can't be a string */
|
|
63
|
+
onSelectedItemChange="test"
|
|
64
|
+
/* @ts-expect-error - selectedItem can't be a string */
|
|
65
|
+
selectedItem="test"
|
|
66
|
+
/* @ts-expect-error - stateReducer must have a return value */
|
|
67
|
+
stateReducer={() => {}}
|
|
68
|
+
/* @ts-expect-error - onMultiSelectStateChange can't be a string */
|
|
69
|
+
onMultiSelectStateChange="test"
|
|
70
|
+
/* @ts-expect-error - selectedItems can't be a string */
|
|
71
|
+
selectedItems="test"
|
|
72
|
+
/* @ts-expect-error - multiSelectStateReducer must have a return value */
|
|
73
|
+
multiSelectStateReducer="test"
|
|
74
|
+
>
|
|
75
|
+
Test
|
|
76
|
+
</MultiSelectMenu>
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./MultiSelectMenu";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Feature: Single Select Menu
|
|
2
|
+
|
|
3
|
+
A Menu for selecting a single item from a list of items.
|
|
4
|
+
|
|
5
|
+
Rule: User Behavior
|
|
6
|
+
Background:
|
|
7
|
+
Given an end user is interacting with the component
|
|
8
|
+
|
|
9
|
+
Scenario: SingleSelectMenu sets a selected item
|
|
10
|
+
When an item is selected
|
|
11
|
+
Then a selectedItem is set
|
|
12
|
+
|
|
13
|
+
Rule: API
|
|
14
|
+
Background:
|
|
15
|
+
Given an engineer is implementing a SingleSelectMenu
|
|
16
|
+
|
|
17
|
+
Scenario: SingleSelectMenu extends Menu
|
|
18
|
+
When Menu features are used in SingleSelectMenu
|
|
19
|
+
Then the SingleSelectMenu responds as a Menu would
|
|
20
|
+
|
|
21
|
+
Scenario: SingleSelectMenu can be set to automatically close after selection
|
|
22
|
+
When the closeMenuOnSelection prop is set to true
|
|
23
|
+
Then the menu automatically closes after an item is selected
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useSelect, type UseSelectState } from "downshift";
|
|
3
|
+
import { useItemsFromChildren } from "../hooks/useItemsFromChildren";
|
|
4
|
+
import {
|
|
5
|
+
defaultUseSelectProps,
|
|
6
|
+
useSelectStateReducer,
|
|
7
|
+
} from "../reducers/useSelectStateReducer";
|
|
8
|
+
import { itemToString } from "../utils/itemToString";
|
|
9
|
+
import { MenuRoot } from "../MenuRoot";
|
|
10
|
+
import type { TypeDefaultItemProps } from "../types";
|
|
11
|
+
import type { TypeSingleSelectMenuProps } from "./SingleSelectMenuTypes";
|
|
12
|
+
|
|
13
|
+
export const SingleSelectMenu = ({
|
|
14
|
+
children,
|
|
15
|
+
stateReducer: externalStateReducer,
|
|
16
|
+
...useSelectProps
|
|
17
|
+
}: TypeSingleSelectMenuProps) => {
|
|
18
|
+
/** Import the manipulated map of menu items from the context */
|
|
19
|
+
const { allMenuItems, menuItemsMap } = useItemsFromChildren({ children });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Destructure Downshift props and call the core useSelect with the new menu items and state handler
|
|
23
|
+
*/
|
|
24
|
+
const { isOpen, ...useSelectReturnProps } = useSelect({
|
|
25
|
+
...defaultUseSelectProps,
|
|
26
|
+
items: allMenuItems,
|
|
27
|
+
itemToString,
|
|
28
|
+
stateReducer: (state, actionAndChanges) => {
|
|
29
|
+
let newState: Partial<UseSelectState<TypeDefaultItemProps>>;
|
|
30
|
+
newState = useSelectStateReducer(state, actionAndChanges);
|
|
31
|
+
return externalStateReducer
|
|
32
|
+
? externalStateReducer(state, {
|
|
33
|
+
...actionAndChanges,
|
|
34
|
+
changes: newState,
|
|
35
|
+
})
|
|
36
|
+
: newState;
|
|
37
|
+
},
|
|
38
|
+
...useSelectProps,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<MenuRoot
|
|
43
|
+
{...{
|
|
44
|
+
isListbox: true,
|
|
45
|
+
items: allMenuItems,
|
|
46
|
+
itemsMap: menuItemsMap,
|
|
47
|
+
itemToString,
|
|
48
|
+
isOpen,
|
|
49
|
+
...useSelectProps,
|
|
50
|
+
...useSelectReturnProps,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</MenuRoot>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { UseSelectProps } from "downshift";
|
|
2
|
+
import type { TypeMenuRootProps, TypeMenuRootOwnProps } from "../MenuRoot";
|
|
3
|
+
import type { TypeDefaultItemProps } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface TypeSingleSelectMenuProps
|
|
6
|
+
extends TypeMenuRootOwnProps,
|
|
7
|
+
Partial<UseSelectProps<TypeDefaultItemProps>> {
|
|
8
|
+
children: TypeMenuRootProps["children"];
|
|
9
|
+
}
|