@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,41 @@
|
|
|
1
|
+
import { MenuItem } from "../MenuItem";
|
|
2
|
+
|
|
3
|
+
export const MenuItemTypeTest = () => (
|
|
4
|
+
<>
|
|
5
|
+
{/* Valid MenuItem with minimal required props */}
|
|
6
|
+
<MenuItem id="test">Test</MenuItem>
|
|
7
|
+
{/* Valid MenuItem allows disabled prop */}
|
|
8
|
+
<MenuItem id="test" disabled>
|
|
9
|
+
Test
|
|
10
|
+
</MenuItem>
|
|
11
|
+
{/* Valid MenuItem allows disabled prop false */}
|
|
12
|
+
<MenuItem id="test" disabled={false}>
|
|
13
|
+
Test
|
|
14
|
+
</MenuItem>
|
|
15
|
+
|
|
16
|
+
{/* @ts-expect-error - no children is invalid */}
|
|
17
|
+
<MenuItem id="foo" />
|
|
18
|
+
{/* @ts-expect-error - test empty states are invalid */}
|
|
19
|
+
<MenuItem />
|
|
20
|
+
{/* @ts-expect-error - test missing index is invalid */}
|
|
21
|
+
<MenuItem id="foo" />
|
|
22
|
+
{/* @ts-expect-error - test missing id is invalid */}
|
|
23
|
+
<MenuItem />
|
|
24
|
+
{/* @ts-expect-error - test that types are invalid */}
|
|
25
|
+
<MenuItem id={0} />
|
|
26
|
+
{/* @ts-expect-error -test that wrong highlighted type fails */}
|
|
27
|
+
<MenuItem $highlighted="string">Hello world!</MenuItem>
|
|
28
|
+
{/* @ts-expect-error -test that wrong role type fails */}
|
|
29
|
+
<MenuItem role="wrong">Hello world!</MenuItem>
|
|
30
|
+
{/* @ts-expect-error - missing id is invalid */}
|
|
31
|
+
<MenuItem>Test</MenuItem>
|
|
32
|
+
<MenuItem
|
|
33
|
+
/* @ts-expect-error - id may not be a number */
|
|
34
|
+
id={0}
|
|
35
|
+
/* @ts-expect-error - disabled may not be a string */
|
|
36
|
+
disabled="true"
|
|
37
|
+
>
|
|
38
|
+
Test
|
|
39
|
+
</MenuItem>
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import styled, { css } from "styled-components";
|
|
2
|
+
import { focusRing, disabled } from "@sproutsocial/seeds-react-mixins";
|
|
3
|
+
import { color, border, flexbox, grid, layout, space } from "styled-system";
|
|
4
|
+
import {
|
|
5
|
+
CheckboxContainer,
|
|
6
|
+
InputWrapper,
|
|
7
|
+
PillInput,
|
|
8
|
+
checkboxContainerHoverStyles,
|
|
9
|
+
inputWrapperHoverStyles,
|
|
10
|
+
pillInputHoverStyles,
|
|
11
|
+
} from "@sproutsocial/seeds-react-checkbox";
|
|
12
|
+
|
|
13
|
+
const highlightedStyles = css`
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
border: 1px solid ${({ theme }) => theme.colors.listItem.border.base};
|
|
16
|
+
background-color: ${({ theme }) => theme.colors.listItem.background.hover};
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
export const MenuItemContainer = styled.li<{ $highlighted: boolean }>`
|
|
20
|
+
border-radius: ${({ theme }) => theme.radii[500]};
|
|
21
|
+
background-color: ${({ theme }) => theme.colors.listItem.background.base};
|
|
22
|
+
border: 1px solid ${({ theme }) => theme.colors.listItem.background.base};
|
|
23
|
+
color: ${({ theme }) => theme.colors.text.body};
|
|
24
|
+
font-family: ${({ theme }) => theme.fontFamily};
|
|
25
|
+
font-weight: ${({ theme }) => theme.fontWeights.normal};
|
|
26
|
+
padding: ${({ theme }) => theme.space[300]};
|
|
27
|
+
list-style-type: none;
|
|
28
|
+
outline: 0;
|
|
29
|
+
${({ theme }) => theme.typography[200]};
|
|
30
|
+
transition: all ${({ theme }) => theme.duration["fast"]};
|
|
31
|
+
|
|
32
|
+
/* Highlighted */
|
|
33
|
+
${({ $highlighted }) => $highlighted && highlightedStyles}
|
|
34
|
+
|
|
35
|
+
/* Hovered, but not focused */
|
|
36
|
+
&:hover {
|
|
37
|
+
${highlightedStyles}
|
|
38
|
+
${CheckboxContainer} {
|
|
39
|
+
${checkboxContainerHoverStyles}
|
|
40
|
+
}
|
|
41
|
+
${InputWrapper} {
|
|
42
|
+
${inputWrapperHoverStyles}
|
|
43
|
+
}
|
|
44
|
+
${PillInput} {
|
|
45
|
+
${pillInputHoverStyles}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* focused */
|
|
50
|
+
&:focus {
|
|
51
|
+
${focusRing}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Pressed state*/
|
|
55
|
+
&:active {
|
|
56
|
+
background-color: ${({ theme }) => theme.colors.listItem.background.active};
|
|
57
|
+
color: ${({ theme }) => theme.colors.text.inverse};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
&[aria-disabled="true"] {
|
|
61
|
+
cursor: not-allowed;
|
|
62
|
+
${disabled}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
${border}
|
|
66
|
+
${color}
|
|
67
|
+
${flexbox}
|
|
68
|
+
${grid}
|
|
69
|
+
${layout}
|
|
70
|
+
${space}
|
|
71
|
+
`;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Feature: MenuItemSelectable
|
|
2
|
+
|
|
3
|
+
A selectable item designed to be included within a Menu component, providing different input types
|
|
4
|
+
|
|
5
|
+
Rule: User Behavior
|
|
6
|
+
|
|
7
|
+
Background:
|
|
8
|
+
Given an end user is interacting with the component
|
|
9
|
+
|
|
10
|
+
Scenario Outline: MenuItemSelectable can be checked
|
|
11
|
+
Given the MenuItemSelectable is in the unchecked state
|
|
12
|
+
And the MenuItemSelectable is focused
|
|
13
|
+
And the MenuItemSelectable is not disabled
|
|
14
|
+
When the MenuItemSelectable is triggered via <interaction>
|
|
15
|
+
Then the MenuItemSelectable should be checked
|
|
16
|
+
|
|
17
|
+
Examples: Interactions
|
|
18
|
+
| interaction |
|
|
19
|
+
| clicking with mouse |
|
|
20
|
+
| pressing the Enter key |
|
|
21
|
+
| pressing the Space key |
|
|
22
|
+
|
|
23
|
+
Scenario Outline: MenuItemSelectable can be unchecked
|
|
24
|
+
Given the MenuItemSelectable is in the checked state
|
|
25
|
+
And the MenuItemSelectable is focused
|
|
26
|
+
And the MenuItemSelectable is not disabled
|
|
27
|
+
When the MenuItemSelectable is triggered via <interaction>
|
|
28
|
+
Then the MenuItemSelectable should be unchecked
|
|
29
|
+
|
|
30
|
+
Examples: Interactions
|
|
31
|
+
| interaction |
|
|
32
|
+
| clicking with mouse |
|
|
33
|
+
| pressing the Enter key |
|
|
34
|
+
| pressing the Space key |
|
|
35
|
+
|
|
36
|
+
Scenario Outline: MenuItemSelectable is disabled
|
|
37
|
+
Given the MenuItemSelectable is disabled
|
|
38
|
+
And the MenuItemSelectable is focused
|
|
39
|
+
When the MenuItemSelectable is triggered via <interaction>
|
|
40
|
+
Then no events should fire
|
|
41
|
+
|
|
42
|
+
Examples: Interactions
|
|
43
|
+
| interaction |
|
|
44
|
+
| clicking with mouse |
|
|
45
|
+
| pressing the Enter key |
|
|
46
|
+
| pressing the Space key |
|
|
47
|
+
|
|
48
|
+
Rule: API
|
|
49
|
+
Background:
|
|
50
|
+
Given an engineer is developing using the component
|
|
51
|
+
|
|
52
|
+
Scenario: MenuItemSelectable accepts children
|
|
53
|
+
When <child component(s)> are provided
|
|
54
|
+
Then the MenuItemSelectable should render the <component(s)>
|
|
55
|
+
|
|
56
|
+
Examples: child components
|
|
57
|
+
| child component(s) | component(s) |
|
|
58
|
+
| a Link is | Link |
|
|
59
|
+
| a Text is | Text |
|
|
60
|
+
| a Icon is | Icon |
|
|
61
|
+
| a Text then Icon are | Text and Icon |
|
|
62
|
+
| a Icon and Text are | Icon and Text |
|
|
63
|
+
|
|
64
|
+
Scenario Outline: MenuItemSelectable supports different input types
|
|
65
|
+
When an <inputTypeProp> is passed to the MenuItemSelectable component
|
|
66
|
+
Then the MenuItemSelectable renders with the appropriate <component> type
|
|
67
|
+
|
|
68
|
+
Examples: Input Types
|
|
69
|
+
| inputTypeProp | component |
|
|
70
|
+
| checkbox | Checkbox |
|
|
71
|
+
| radio | Radio |
|
|
72
|
+
| switch | Switch |
|
|
73
|
+
|
|
74
|
+
Scenario: MenuItemSelectable defaults to an icon input type
|
|
75
|
+
When the MenuItemSelectable is implemented without an inputType prop specified
|
|
76
|
+
Then the MenuItemSelectable defaults to an icon input type
|
|
77
|
+
|
|
78
|
+
Scenario: MenuItemSelectable checked state can be enabled via a prop
|
|
79
|
+
When the selected prop is set to true
|
|
80
|
+
Then the MenuItemSelectable should appear in the checked state
|
|
81
|
+
|
|
82
|
+
Scenario Outline: MenuItemSelectable validates prop types
|
|
83
|
+
When any <propName> is passed to the MenuItemSelectable
|
|
84
|
+
Then the MenuItemSelectable should check that each <propName> is of the expected <propType>
|
|
85
|
+
|
|
86
|
+
Examples: Prop Types
|
|
87
|
+
| propName | propType |
|
|
88
|
+
| children | ReactNode |
|
|
89
|
+
| disabled | boolean |
|
|
90
|
+
| elevation | low, medium, or high |
|
|
91
|
+
| href | string |
|
|
92
|
+
| onClick | React.MouseEventHandler |
|
|
93
|
+
| role | string |
|
|
94
|
+
| selected | boolean |
|
|
95
|
+
| inputType | checkbox, radio, or switch |
|
|
96
|
+
|
|
97
|
+
Scenario: MenuItemSelectable extends MenuItem
|
|
98
|
+
When MenuItemSelectable features are used
|
|
99
|
+
Then the MenuItemSelectable responds as a MenuItem would
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { useContext, useMemo } from "react";
|
|
2
|
+
import { Icon } from "@sproutsocial/seeds-react-icon";
|
|
3
|
+
import { Checkbox } from "@sproutsocial/seeds-react-checkbox";
|
|
4
|
+
import { Switch } from "@sproutsocial/seeds-react-switch";
|
|
5
|
+
import { Radio } from "@sproutsocial/seeds-react-radio";
|
|
6
|
+
import { useTheme } from "styled-components";
|
|
7
|
+
import { Box } from "@sproutsocial/seeds-react-box";
|
|
8
|
+
import { MenuContext } from "../MenuContext";
|
|
9
|
+
import { MenuItem } from "../MenuItem";
|
|
10
|
+
|
|
11
|
+
// import { type TypeMenuItemRoles } from "../constants";
|
|
12
|
+
import {
|
|
13
|
+
MenuItemSelectableAction,
|
|
14
|
+
MenuItemSelectableRow,
|
|
15
|
+
MenuItemSelectableText,
|
|
16
|
+
} from "./styles";
|
|
17
|
+
import type {
|
|
18
|
+
TypeMenuItemSelectableProps,
|
|
19
|
+
InputType,
|
|
20
|
+
} from "./MenuItemSelectableTypes";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* SelectionIndicator - handles logic for appropriate rendering of inputs
|
|
24
|
+
* @returns Checkbox | Icon | Radio | Switch
|
|
25
|
+
*/
|
|
26
|
+
export const SelectionIndicator = ({
|
|
27
|
+
id,
|
|
28
|
+
inputType = "icon",
|
|
29
|
+
"aria-labelledby": labeledBy,
|
|
30
|
+
selected,
|
|
31
|
+
}: {
|
|
32
|
+
id: string;
|
|
33
|
+
"aria-labelledby": string;
|
|
34
|
+
selected: boolean;
|
|
35
|
+
inputType: InputType;
|
|
36
|
+
}) => {
|
|
37
|
+
const { space } = useTheme();
|
|
38
|
+
|
|
39
|
+
const inputStub = (e: React.SyntheticEvent) => {
|
|
40
|
+
e.stopPropagation();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
switch (inputType) {
|
|
44
|
+
case "checkbox":
|
|
45
|
+
return (
|
|
46
|
+
<Checkbox
|
|
47
|
+
onChange={() => {}}
|
|
48
|
+
checked={selected}
|
|
49
|
+
aria-labelledby={labeledBy}
|
|
50
|
+
id={`MenuItemSelectable-${inputType}-${id}`}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
case "switch":
|
|
54
|
+
return (
|
|
55
|
+
<Switch
|
|
56
|
+
onClick={inputStub}
|
|
57
|
+
checked={!!selected}
|
|
58
|
+
aria-labelledby={labeledBy}
|
|
59
|
+
id={`MenuItemSelectable-${inputType}-${id}`}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
case "radio":
|
|
63
|
+
return (
|
|
64
|
+
<Radio
|
|
65
|
+
checked={selected}
|
|
66
|
+
id={`MenuItemSelectable-${inputType}-${id}`}
|
|
67
|
+
name={`MenuItemSelectable-${inputType}`}
|
|
68
|
+
aria-labelledby={labeledBy}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
case "icon":
|
|
72
|
+
if (selected) {
|
|
73
|
+
return <Icon name="check-solid" aria-hidden size="small" />;
|
|
74
|
+
} else {
|
|
75
|
+
return <Box width={space["400"]}></Box>;
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* MenuItemSelectable - primitive with controlled input states
|
|
84
|
+
*/
|
|
85
|
+
export const MenuItemSelectable = ({
|
|
86
|
+
id,
|
|
87
|
+
children,
|
|
88
|
+
inputType = "icon",
|
|
89
|
+
inputLabel,
|
|
90
|
+
...props
|
|
91
|
+
}: TypeMenuItemSelectableProps) => {
|
|
92
|
+
const { selectedItem, selectedItems, isListbox } = useContext(MenuContext);
|
|
93
|
+
const selected = useMemo(
|
|
94
|
+
() =>
|
|
95
|
+
selectedItem?.id === id ||
|
|
96
|
+
!!selectedItems?.find((item) => item?.id === id),
|
|
97
|
+
[selectedItem, selectedItems]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (isListbox === false) {
|
|
101
|
+
console.warn(
|
|
102
|
+
"MenuItemSelectable is invalid when isListbox is false (Menu)"
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// const roles: Record<InputType, TypeMenuItemRoles> = {
|
|
107
|
+
// checkbox: "menuitemcheckbox",
|
|
108
|
+
// switch: "menuitemcheckbox",
|
|
109
|
+
// radio: "menuitemradio",
|
|
110
|
+
// icon: "option",
|
|
111
|
+
// };
|
|
112
|
+
|
|
113
|
+
const label = inputLabel || `menu-item-child-${id}`;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<MenuItem
|
|
117
|
+
active={selected}
|
|
118
|
+
id={id}
|
|
119
|
+
// role={roles["icon"]}
|
|
120
|
+
aria-selected={selected}
|
|
121
|
+
{...props}
|
|
122
|
+
>
|
|
123
|
+
<MenuItemSelectableRow>
|
|
124
|
+
<MenuItemSelectableAction>
|
|
125
|
+
<SelectionIndicator
|
|
126
|
+
id={id}
|
|
127
|
+
aria-labelledby={label}
|
|
128
|
+
inputType={inputType}
|
|
129
|
+
selected={!!selected}
|
|
130
|
+
/>
|
|
131
|
+
</MenuItemSelectableAction>
|
|
132
|
+
<MenuItemSelectableText id={label}>{children}</MenuItemSelectableText>
|
|
133
|
+
</MenuItemSelectableRow>
|
|
134
|
+
</MenuItem>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TypeMenuItemProps } from "../MenuItem";
|
|
2
|
+
|
|
3
|
+
export const INPUT_TYPES = {
|
|
4
|
+
CHECKBOX: "checkbox",
|
|
5
|
+
RADIO: "radio",
|
|
6
|
+
SWITCH: "switch",
|
|
7
|
+
ICON: "icon",
|
|
8
|
+
} as const;
|
|
9
|
+
export type InputType = (typeof INPUT_TYPES)[keyof typeof INPUT_TYPES];
|
|
10
|
+
|
|
11
|
+
export interface TypeMenuItemSelectableProps extends TypeMenuItemProps {
|
|
12
|
+
active?: boolean;
|
|
13
|
+
inputType?: InputType;
|
|
14
|
+
inputLabel?: string;
|
|
15
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/* eslint-disable testing-library/no-unnecessary-act */
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { render, screen, act } from "@sproutsocial/seeds-react-testing-library";
|
|
4
|
+
import { PointerEventsCheckLevel } from "@testing-library/user-event";
|
|
5
|
+
import { type UserEvent } from "@testing-library/user-event";
|
|
6
|
+
import {
|
|
7
|
+
MenuContent,
|
|
8
|
+
MenuToggleButton,
|
|
9
|
+
MenuItemSelectable,
|
|
10
|
+
MultiSelectMenu,
|
|
11
|
+
INPUT_TYPES,
|
|
12
|
+
} from "../../index";
|
|
13
|
+
import { Button } from "@sproutsocial/seeds-react-button";
|
|
14
|
+
import { TestWithMenuRoot, TestWithMockedMenu } from "../../menuTestingUtils";
|
|
15
|
+
|
|
16
|
+
const books = [
|
|
17
|
+
{
|
|
18
|
+
index: 0,
|
|
19
|
+
id: "book-1",
|
|
20
|
+
author: "Harper Lee",
|
|
21
|
+
title: "To Kill a Mockingbird",
|
|
22
|
+
},
|
|
23
|
+
{ index: 1, id: "book-2", author: "Lev Tolstoy", title: "War and Peace" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const testByType = {
|
|
27
|
+
[INPUT_TYPES.CHECKBOX]: {
|
|
28
|
+
isSelected: () => {
|
|
29
|
+
expect(
|
|
30
|
+
screen.getByRole("checkbox", { checked: true })
|
|
31
|
+
).toBeInTheDocument();
|
|
32
|
+
},
|
|
33
|
+
isNotSelected: () => {
|
|
34
|
+
expect(
|
|
35
|
+
screen.queryByRole("checkbox", { checked: true })
|
|
36
|
+
).not.toBeInTheDocument();
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
[INPUT_TYPES.RADIO]: {
|
|
40
|
+
isSelected: () => {
|
|
41
|
+
expect(screen.getByRole("radio", { checked: true })).toBeInTheDocument();
|
|
42
|
+
},
|
|
43
|
+
isNotSelected: () => {
|
|
44
|
+
expect(
|
|
45
|
+
screen.queryByRole("radio", { checked: true })
|
|
46
|
+
).not.toBeInTheDocument();
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
[INPUT_TYPES.SWITCH]: {
|
|
50
|
+
isSelected: () => {
|
|
51
|
+
expect(screen.getByRole("switch", { checked: true })).toBeInTheDocument();
|
|
52
|
+
},
|
|
53
|
+
isNotSelected: () => {
|
|
54
|
+
expect(
|
|
55
|
+
screen.queryByRole("switch", { checked: true })
|
|
56
|
+
).not.toBeInTheDocument();
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
[INPUT_TYPES.ICON]: {
|
|
60
|
+
isSelected: () => {
|
|
61
|
+
expect(
|
|
62
|
+
screen.getByDataQaLabel({ icon: "check-solid" })
|
|
63
|
+
).toBeInTheDocument();
|
|
64
|
+
},
|
|
65
|
+
isNotSelected: () => {
|
|
66
|
+
expect(
|
|
67
|
+
screen.queryByDataQaLabel({ icon: "check-solid" })
|
|
68
|
+
).not.toBeInTheDocument();
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe("MenuItemSelectable ", () => {
|
|
74
|
+
describe("User Behavior", () => {
|
|
75
|
+
xdescribe.each([
|
|
76
|
+
INPUT_TYPES.CHECKBOX,
|
|
77
|
+
INPUT_TYPES.RADIO,
|
|
78
|
+
INPUT_TYPES.SWITCH,
|
|
79
|
+
INPUT_TYPES.ICON,
|
|
80
|
+
])("when the input type is %s", (inputType) => {
|
|
81
|
+
test.each([
|
|
82
|
+
{
|
|
83
|
+
action: "clicking with mouse",
|
|
84
|
+
activate: async (user: UserEvent) => {
|
|
85
|
+
const firstItem = screen.getByRole("option", {
|
|
86
|
+
name: books[0].title,
|
|
87
|
+
});
|
|
88
|
+
await user
|
|
89
|
+
.setup({
|
|
90
|
+
pointerEventsCheck: PointerEventsCheckLevel.Never,
|
|
91
|
+
})
|
|
92
|
+
.click(firstItem);
|
|
93
|
+
},
|
|
94
|
+
deactivate: async (user: UserEvent) => {
|
|
95
|
+
const firstItem = screen.getByRole("option", {
|
|
96
|
+
name: books[0].title,
|
|
97
|
+
});
|
|
98
|
+
await user
|
|
99
|
+
.setup({
|
|
100
|
+
pointerEventsCheck: PointerEventsCheckLevel.Never,
|
|
101
|
+
})
|
|
102
|
+
.click(firstItem);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
action: "pressing the Enter key",
|
|
107
|
+
activate: async (user: UserEvent) => {
|
|
108
|
+
await user.keyboard("{arrowdown}{enter}");
|
|
109
|
+
},
|
|
110
|
+
deactivate: async (user: UserEvent) => {
|
|
111
|
+
await user.keyboard("{enter}");
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
action: "pressing the Space key",
|
|
116
|
+
activate: async (user: UserEvent) => {
|
|
117
|
+
await user.keyboard("{arrowdown}{space}");
|
|
118
|
+
},
|
|
119
|
+
deactivate: async (user: UserEvent) => {
|
|
120
|
+
await user.keyboard("{space}");
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
])(
|
|
124
|
+
"can be checked and unchecked when $action",
|
|
125
|
+
async ({ activate, deactivate }) => {
|
|
126
|
+
const { user } = render(
|
|
127
|
+
<MultiSelectMenu
|
|
128
|
+
triggerElement={<MenuToggleButton>Open</MenuToggleButton>}
|
|
129
|
+
defaultIsOpen
|
|
130
|
+
>
|
|
131
|
+
<MenuContent>
|
|
132
|
+
{books.map((book) => {
|
|
133
|
+
return (
|
|
134
|
+
<MenuItemSelectable
|
|
135
|
+
key={book.id}
|
|
136
|
+
id={book.id}
|
|
137
|
+
inputType={inputType}
|
|
138
|
+
>
|
|
139
|
+
{book.title}
|
|
140
|
+
</MenuItemSelectable>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
</MenuContent>
|
|
144
|
+
</MultiSelectMenu>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// check that menu is visible
|
|
148
|
+
expect(
|
|
149
|
+
screen.getByRole("option", { name: books[0].title })
|
|
150
|
+
).toBeInTheDocument();
|
|
151
|
+
|
|
152
|
+
// Check that checkboxes are rendered
|
|
153
|
+
if (inputType !== INPUT_TYPES.ICON)
|
|
154
|
+
expect(screen.getAllByRole(inputType)).toHaveLength(books.length);
|
|
155
|
+
|
|
156
|
+
// Perform the action to select the item
|
|
157
|
+
await activate(user);
|
|
158
|
+
|
|
159
|
+
expect(
|
|
160
|
+
await screen.findByRole("option", { selected: true })
|
|
161
|
+
).toBeInTheDocument();
|
|
162
|
+
|
|
163
|
+
testByType[inputType].isSelected();
|
|
164
|
+
|
|
165
|
+
// Perform the action to deselect the item
|
|
166
|
+
deactivate(user);
|
|
167
|
+
|
|
168
|
+
expect(
|
|
169
|
+
await screen.findByRole("option", {
|
|
170
|
+
name: books[0].title,
|
|
171
|
+
selected: false,
|
|
172
|
+
})
|
|
173
|
+
).toBeInTheDocument();
|
|
174
|
+
|
|
175
|
+
testByType[inputType].isNotSelected();
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("API", () => {
|
|
182
|
+
it("accepts children", () => {
|
|
183
|
+
render(
|
|
184
|
+
<MenuItemSelectable id="test-menu-item-selectable">
|
|
185
|
+
Hello world!
|
|
186
|
+
<Button>Hello menu item!</Button>
|
|
187
|
+
</MenuItemSelectable>
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const menuItemSelectable = screen.getByText("Hello world!");
|
|
191
|
+
const button = screen.getByText("Hello menu item!");
|
|
192
|
+
expect(menuItemSelectable).toContainElement(button);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test.each([INPUT_TYPES.CHECKBOX, INPUT_TYPES.RADIO, INPUT_TYPES.SWITCH])(
|
|
196
|
+
"supports %s input types",
|
|
197
|
+
(inputType) => {
|
|
198
|
+
render(
|
|
199
|
+
<MenuItemSelectable
|
|
200
|
+
id="test-menu-item-selectable"
|
|
201
|
+
inputType={inputType}
|
|
202
|
+
>
|
|
203
|
+
Hello world!
|
|
204
|
+
</MenuItemSelectable>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
expect(screen.getByRole(inputType)).toBeInTheDocument();
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
it("uses the check icon by default when no inputType provided", () => {
|
|
212
|
+
const testItem = books[0];
|
|
213
|
+
render(
|
|
214
|
+
<TestWithMockedMenu context={{ items: books, selectedItem: testItem }}>
|
|
215
|
+
<MenuItemSelectable id={testItem.id}>
|
|
216
|
+
{testItem.title}
|
|
217
|
+
</MenuItemSelectable>
|
|
218
|
+
</TestWithMockedMenu>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(
|
|
222
|
+
screen.getByDataQaLabel({ icon: "check-solid" })
|
|
223
|
+
).toBeInTheDocument();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe.each([
|
|
227
|
+
INPUT_TYPES.CHECKBOX,
|
|
228
|
+
INPUT_TYPES.RADIO,
|
|
229
|
+
INPUT_TYPES.SWITCH,
|
|
230
|
+
INPUT_TYPES.ICON,
|
|
231
|
+
])("when the input type is %s", (inputType) => {
|
|
232
|
+
it("enables it's checked state when selected in the context", () => {
|
|
233
|
+
const testItem = books[0];
|
|
234
|
+
render(
|
|
235
|
+
<TestWithMockedMenu
|
|
236
|
+
context={{ items: books, selectedItem: testItem }}
|
|
237
|
+
>
|
|
238
|
+
<MenuItemSelectable id={testItem.id} inputType={inputType}>
|
|
239
|
+
{testItem.title}
|
|
240
|
+
</MenuItemSelectable>
|
|
241
|
+
</TestWithMockedMenu>
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
screen.getByRole("option", { selected: true });
|
|
245
|
+
testByType[inputType].isSelected();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("enables it's checked state when selected by default", () => {
|
|
249
|
+
const testItem = books[0];
|
|
250
|
+
render(
|
|
251
|
+
<TestWithMenuRoot
|
|
252
|
+
triggerElement={<MenuToggleButton>Open</MenuToggleButton>}
|
|
253
|
+
{...{
|
|
254
|
+
isOpen: true,
|
|
255
|
+
items: books,
|
|
256
|
+
selectedItem: testItem,
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
<MenuItemSelectable id={testItem.id} inputType={inputType}>
|
|
260
|
+
{testItem.title}
|
|
261
|
+
</MenuItemSelectable>
|
|
262
|
+
</TestWithMenuRoot>
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
screen.getByRole("option", { selected: true });
|
|
266
|
+
testByType[inputType].isSelected();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("allows its checked state to be deselected when using the context", async () => {
|
|
270
|
+
const testItem = books[0];
|
|
271
|
+
const TestItemWithState = () => {
|
|
272
|
+
const [selectedItem, setSelectedItem] = React.useState<
|
|
273
|
+
typeof testItem | undefined
|
|
274
|
+
>(testItem);
|
|
275
|
+
return (
|
|
276
|
+
<TestWithMockedMenu context={{ items: books, selectedItem }}>
|
|
277
|
+
<MenuItemSelectable
|
|
278
|
+
id={testItem.id}
|
|
279
|
+
inputType={inputType}
|
|
280
|
+
onClick={() => setSelectedItem(undefined)}
|
|
281
|
+
>
|
|
282
|
+
{testItem.title}
|
|
283
|
+
</MenuItemSelectable>
|
|
284
|
+
</TestWithMockedMenu>
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
const { user } = render(<TestItemWithState />);
|
|
288
|
+
|
|
289
|
+
const menuItem = screen.getByRole("option", { selected: true });
|
|
290
|
+
testByType[inputType].isSelected();
|
|
291
|
+
|
|
292
|
+
await user.click(menuItem);
|
|
293
|
+
|
|
294
|
+
expect(
|
|
295
|
+
await screen.findByRole("option", {
|
|
296
|
+
selected: false,
|
|
297
|
+
})
|
|
298
|
+
).toBeInTheDocument();
|
|
299
|
+
testByType[inputType].isNotSelected();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|