@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,59 @@
|
|
|
1
|
+
Feature: Menu Header
|
|
2
|
+
|
|
3
|
+
The top area of a Menu.
|
|
4
|
+
|
|
5
|
+
Rule: API
|
|
6
|
+
Background:
|
|
7
|
+
Given an engineer is implementing a MenuHeader
|
|
8
|
+
|
|
9
|
+
Scenario: Display MenuHeader with a title
|
|
10
|
+
When the "title" prop is provided
|
|
11
|
+
And the title is a String
|
|
12
|
+
Then the MenuHeader should display the title
|
|
13
|
+
|
|
14
|
+
Scenario: Display MenuHeader with an empty or non-string title
|
|
15
|
+
When an empty or non-string title is provided
|
|
16
|
+
Then the MenuHeader should not display the title
|
|
17
|
+
|
|
18
|
+
Scenario Outline: MenuHeader supports children
|
|
19
|
+
When <child component(s)> provided
|
|
20
|
+
Then the MenuHeader should render the <component(s)>
|
|
21
|
+
|
|
22
|
+
Examples: Child Components
|
|
23
|
+
| child component(s) | component(s) |
|
|
24
|
+
| Text is | Text |
|
|
25
|
+
| a Banner is | Banner |
|
|
26
|
+
| a MenuHeaderInput is | MenuHeaderInput |
|
|
27
|
+
| Text and a Banner are | Text and Banner |
|
|
28
|
+
|
|
29
|
+
Scenario Outline: Actions can be triggered from the MenuHeader
|
|
30
|
+
Given that actionable elements are provided to the MenuHeader
|
|
31
|
+
When a <action> is clicked
|
|
32
|
+
Then the corresponding onClick should be executed
|
|
33
|
+
|
|
34
|
+
Examples: Action Types
|
|
35
|
+
| action |
|
|
36
|
+
| back button |
|
|
37
|
+
| link button |
|
|
38
|
+
|
|
39
|
+
Scenario Outline: Actions can appear on the left or right of the MenuHeader title
|
|
40
|
+
When an action is provided <action position>
|
|
41
|
+
Then the action(s) should be rendered
|
|
42
|
+
|
|
43
|
+
Examples: action position
|
|
44
|
+
| before the header title |
|
|
45
|
+
| after the header title |
|
|
46
|
+
| before and after the header title |
|
|
47
|
+
|
|
48
|
+
Scenario: MenuHeader extends Box
|
|
49
|
+
When Box features are used
|
|
50
|
+
Then the MenuHeader responds as a Box would
|
|
51
|
+
|
|
52
|
+
Scenario Outline: MenuHeader checks its types
|
|
53
|
+
When <propName> is passed a value
|
|
54
|
+
Then MenuHeader expects it to be of type <propType>
|
|
55
|
+
|
|
56
|
+
Examples: Prop Types
|
|
57
|
+
| propName | propType |
|
|
58
|
+
| title | String |
|
|
59
|
+
| children | ReactNode |
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
MenuHeaderContainer,
|
|
4
|
+
MenuTitleText,
|
|
5
|
+
MenuChildrenContainer,
|
|
6
|
+
} from "./styles";
|
|
7
|
+
import type { TypeMenuHeaderProps } from "./MenuHeaderTypes";
|
|
8
|
+
|
|
9
|
+
const MenuHeader = ({
|
|
10
|
+
children,
|
|
11
|
+
title,
|
|
12
|
+
leftAction,
|
|
13
|
+
rightAction,
|
|
14
|
+
...rest
|
|
15
|
+
}: TypeMenuHeaderProps) => {
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<MenuHeaderContainer $leftAction={!!leftAction} {...rest}>
|
|
19
|
+
{leftAction && leftAction}
|
|
20
|
+
{title && (
|
|
21
|
+
<MenuTitleText $leftAction={!!leftAction}>{title}</MenuTitleText>
|
|
22
|
+
)}
|
|
23
|
+
{rightAction && rightAction}
|
|
24
|
+
{children && <MenuChildrenContainer>{children}</MenuChildrenContainer>}
|
|
25
|
+
</MenuHeaderContainer>
|
|
26
|
+
</>
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export { MenuHeader };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TypeBorderSystemProps,
|
|
4
|
+
TypeColorSystemProps,
|
|
5
|
+
TypeFlexboxSystemProps,
|
|
6
|
+
TypeGridSystemProps,
|
|
7
|
+
TypeLayoutSystemProps,
|
|
8
|
+
TypePositionSystemProps,
|
|
9
|
+
TypeSpaceSystemProps,
|
|
10
|
+
TypeStyledComponentsCommonProps,
|
|
11
|
+
} from "@sproutsocial/seeds-react-system-props";
|
|
12
|
+
|
|
13
|
+
export interface TypeMenuHeaderProps
|
|
14
|
+
extends Omit<React.ComponentPropsWithoutRef<"div">, "color">,
|
|
15
|
+
TypeStyledComponentsCommonProps,
|
|
16
|
+
TypeBorderSystemProps,
|
|
17
|
+
TypeColorSystemProps,
|
|
18
|
+
TypeFlexboxSystemProps,
|
|
19
|
+
TypeGridSystemProps,
|
|
20
|
+
TypeLayoutSystemProps,
|
|
21
|
+
TypeSpaceSystemProps,
|
|
22
|
+
TypePositionSystemProps {
|
|
23
|
+
title?: string;
|
|
24
|
+
leftAction?: React.ReactNode;
|
|
25
|
+
rightAction?: React.ReactNode;
|
|
26
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
2
|
+
import { MenuHeader } from "../MenuHeader";
|
|
3
|
+
import Button from "@sproutsocial/seeds-react-button";
|
|
4
|
+
|
|
5
|
+
describe("MenuHeader API", () => {
|
|
6
|
+
it("renders a title when title prop is passed a string", () => {
|
|
7
|
+
render(<MenuHeader title="Menu Header" />);
|
|
8
|
+
|
|
9
|
+
expect(screen.getByText("Menu Header")).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("doesn't render a title when title prop is empty", () => {
|
|
13
|
+
render(<MenuHeader />);
|
|
14
|
+
const menuHeader = screen.queryByRole("heading");
|
|
15
|
+
|
|
16
|
+
expect(menuHeader).not.toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("supports children", () => {
|
|
20
|
+
render(<MenuHeader>Hello World</MenuHeader>);
|
|
21
|
+
|
|
22
|
+
expect(screen.getByText("Hello World")).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("supports clickable actions", async () => {
|
|
26
|
+
const handleTestClick = jest.fn();
|
|
27
|
+
|
|
28
|
+
const { user } = render(
|
|
29
|
+
<MenuHeader
|
|
30
|
+
leftAction={<Button onClick={handleTestClick}>Left Action</Button>}
|
|
31
|
+
rightAction={<Button onClick={handleTestClick}>Right Action</Button>}
|
|
32
|
+
>
|
|
33
|
+
Hello World
|
|
34
|
+
</MenuHeader>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const leftAction = screen.getByText("Left Action");
|
|
38
|
+
const rightAction = screen.getByText("Right Action");
|
|
39
|
+
|
|
40
|
+
await user.click(leftAction);
|
|
41
|
+
|
|
42
|
+
expect(handleTestClick).toHaveBeenCalledTimes(1);
|
|
43
|
+
|
|
44
|
+
await user.click(rightAction);
|
|
45
|
+
|
|
46
|
+
expect(handleTestClick).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("can render actions on the left or right of the title", () => {
|
|
50
|
+
render(
|
|
51
|
+
<MenuHeader
|
|
52
|
+
leftAction={<Button onClick={() => {}}>Left Action</Button>}
|
|
53
|
+
rightAction={<Button onClick={() => {}}>Right Action</Button>}
|
|
54
|
+
title="Menu Header"
|
|
55
|
+
>
|
|
56
|
+
Hello World
|
|
57
|
+
</MenuHeader>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const menuHeader = screen.getByText("Menu Header");
|
|
61
|
+
const leftAction = screen.getByText("Left Action");
|
|
62
|
+
const rightAction = screen.getByText("Right Action");
|
|
63
|
+
|
|
64
|
+
expect(menuHeader.compareDocumentPosition(leftAction)).toBe(2);
|
|
65
|
+
expect(menuHeader.compareDocumentPosition(rightAction)).toBe(4);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { MenuHeader } from "../MenuHeader";
|
|
2
|
+
|
|
3
|
+
export const MenuHeaderTypeTest = () => (
|
|
4
|
+
<>
|
|
5
|
+
<MenuHeader
|
|
6
|
+
title="Menu Header"
|
|
7
|
+
leftAction={<div>left action</div>}
|
|
8
|
+
rightAction={<div>right action</div>}
|
|
9
|
+
children={<div>children</div>}
|
|
10
|
+
/>
|
|
11
|
+
{/* left and right action accept strings */}
|
|
12
|
+
<MenuHeader leftAction="left" rightAction="right" />
|
|
13
|
+
{/* left and right action accept booleans */}
|
|
14
|
+
<MenuHeader leftAction={false} rightAction={true} />
|
|
15
|
+
{/* left and right action accept undefined */}
|
|
16
|
+
<MenuHeader leftAction={undefined} rightAction={undefined} />
|
|
17
|
+
{/* left and right action accept numbers */}
|
|
18
|
+
<MenuHeader leftAction={123} rightAction={123} />
|
|
19
|
+
{/* left and right action accept null */}
|
|
20
|
+
<MenuHeader leftAction={null} rightAction={null} />
|
|
21
|
+
|
|
22
|
+
{/* @ts-expect-error - title should error with boolean */}
|
|
23
|
+
<MenuHeader title={true} />
|
|
24
|
+
{/* @ts-expect-error - title should error with numbers */}
|
|
25
|
+
<MenuHeader title={123123} />
|
|
26
|
+
</>
|
|
27
|
+
);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
import {
|
|
3
|
+
border,
|
|
4
|
+
color,
|
|
5
|
+
flexbox,
|
|
6
|
+
grid,
|
|
7
|
+
layout,
|
|
8
|
+
position,
|
|
9
|
+
space,
|
|
10
|
+
} from "styled-system";
|
|
11
|
+
import type { TypeMenuHeaderProps } from "./MenuHeaderTypes";
|
|
12
|
+
|
|
13
|
+
interface TypeMenuHeaderContainerProps extends TypeMenuHeaderProps {
|
|
14
|
+
$leftAction?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const MenuHeaderContainer = styled.div<TypeMenuHeaderContainerProps>`
|
|
18
|
+
border-color: ${({ theme }) => theme.colors.container.border.base};
|
|
19
|
+
border-bottom-width: 1px;
|
|
20
|
+
border-bottom-style: solid;
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
width: 100%;
|
|
23
|
+
font-size: ${({ theme }) => theme.typography[200].fontSize};
|
|
24
|
+
display: flex;
|
|
25
|
+
padding: ${({ theme }) => theme.space[350]} ${({ theme }) => theme.space[400]};
|
|
26
|
+
|
|
27
|
+
> a {
|
|
28
|
+
white-space: nowrap;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&:first-child {
|
|
32
|
+
flex-grow: 1;
|
|
33
|
+
justify-self: start;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&:last-child {
|
|
37
|
+
flex-grow: 1;
|
|
38
|
+
justify-self: end;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
${({ $leftAction, theme }) =>
|
|
42
|
+
$leftAction &&
|
|
43
|
+
`
|
|
44
|
+
padding-left: ${theme.space[350]};
|
|
45
|
+
`}
|
|
46
|
+
|
|
47
|
+
${border}
|
|
48
|
+
${color}
|
|
49
|
+
${flexbox}
|
|
50
|
+
${grid}
|
|
51
|
+
${layout}
|
|
52
|
+
${space}
|
|
53
|
+
${position}
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
export const MenuTitleText = styled.h3<TypeMenuHeaderContainerProps>`
|
|
57
|
+
color: ${({ theme }) => theme.colors.text.headline};
|
|
58
|
+
font-weight: ${({ theme }) => theme.fontWeights.bold};
|
|
59
|
+
margin: 0px;
|
|
60
|
+
flex-grow: 2;
|
|
61
|
+
justify-self: start;
|
|
62
|
+
|
|
63
|
+
${({ $leftAction, theme }) =>
|
|
64
|
+
$leftAction &&
|
|
65
|
+
`
|
|
66
|
+
padding-left: ${theme.space[350]};
|
|
67
|
+
`}
|
|
68
|
+
|
|
69
|
+
${({ theme }) => theme.typography[200]}
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
export const MenuChildrenContainer = styled.div`
|
|
73
|
+
grid-column: 1 / -1;
|
|
74
|
+
margin-top: ${({ theme }) => theme.space[300]};
|
|
75
|
+
`;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Feature: MenuItem
|
|
2
|
+
|
|
3
|
+
A configurable and interactive item for inclusion in a `Menu` component.
|
|
4
|
+
|
|
5
|
+
Rule: User Behavior
|
|
6
|
+
Background:
|
|
7
|
+
Given an end user is using the component
|
|
8
|
+
|
|
9
|
+
Scenario: MenuItem is clickable
|
|
10
|
+
Given a MenuItem is not disabled
|
|
11
|
+
When the MenuItem is clicked
|
|
12
|
+
Then the MenuItem's onClick event should fire
|
|
13
|
+
|
|
14
|
+
Scenario Outline: MenuItem is disabled
|
|
15
|
+
Given a MenuItem is disabled
|
|
16
|
+
When the MenuItem is triggered via <interaction>
|
|
17
|
+
Then no events should fire
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
| interaction |
|
|
21
|
+
| clicking |
|
|
22
|
+
| focusing and pressing the Enter key |
|
|
23
|
+
| focusing and pressing the Space key |
|
|
24
|
+
|
|
25
|
+
Scenario Outline: MenuItem can be triggered via keyboard
|
|
26
|
+
Given a MenuItem is focused
|
|
27
|
+
And the MenuItem is not disabled
|
|
28
|
+
When the MenuItem is triggered via <interaction>
|
|
29
|
+
Then the MenuItem's onClick event should fire
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
| interaction |
|
|
33
|
+
| pressing the Enter key |
|
|
34
|
+
| pressing the Space key |
|
|
35
|
+
|
|
36
|
+
Rule: API
|
|
37
|
+
Background:
|
|
38
|
+
Given an engineer is developing using the component
|
|
39
|
+
|
|
40
|
+
Scenario Outline: MenuItem accepts children
|
|
41
|
+
When <child component(s)> provided
|
|
42
|
+
Then the MenuFooter should render the <component(s)>
|
|
43
|
+
|
|
44
|
+
Examples: child components
|
|
45
|
+
| child component(s) | component(s) |
|
|
46
|
+
| a Link is | Link |
|
|
47
|
+
| a Text is | Text |
|
|
48
|
+
| a Icon is | Icon |
|
|
49
|
+
| a Text then Icon are | Text and Icon |
|
|
50
|
+
| a Icon and Text are | Icon and Text |
|
|
51
|
+
|
|
52
|
+
Scenario Outline: A MenuItem accepts system props
|
|
53
|
+
When a system prop from the category <systemPropCategory> is passed a value
|
|
54
|
+
Then the MenuItem will have <systemPropName> styling
|
|
55
|
+
|
|
56
|
+
Examples: System Props
|
|
57
|
+
| systemPropCategory | systemPropName |
|
|
58
|
+
| color | bg |
|
|
59
|
+
| border | borderColor |
|
|
60
|
+
| layout | display |
|
|
61
|
+
| position | zIndex |
|
|
62
|
+
| flexbox | flex |
|
|
63
|
+
| grid | gridGap |
|
|
64
|
+
| space | p |
|
|
65
|
+
|
|
66
|
+
Scenario Outline: MenuItem validates prop types
|
|
67
|
+
When any <prop> is passed to the MenuItem
|
|
68
|
+
Then the MenuItem should check that each prop is of the expected <propType>
|
|
69
|
+
|
|
70
|
+
Examples: Prop Types
|
|
71
|
+
| prop | propType |
|
|
72
|
+
| children | node |
|
|
73
|
+
| onClick | func |
|
|
74
|
+
| disabled | bool |
|
|
75
|
+
| className | string |
|
|
76
|
+
|
|
77
|
+
Scenario Outline: Screen readers should read MenuItem states
|
|
78
|
+
Given a MenuItem is <state>
|
|
79
|
+
Then screen readers should read the MenuItem as <state>
|
|
80
|
+
|
|
81
|
+
Examples: MenuItem States
|
|
82
|
+
| state |
|
|
83
|
+
| enabled |
|
|
84
|
+
| disabled |
|
|
85
|
+
| hovered |
|
|
86
|
+
| focused |
|
|
87
|
+
| active |
|
|
88
|
+
| pressed |
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useContext } from "react";
|
|
2
|
+
import { MenuContext } from "../MenuContext";
|
|
3
|
+
import { MENU_ITEM_ROLES } from "../constants";
|
|
4
|
+
import type { TypeDefaultItemProps } from "../types";
|
|
5
|
+
import { MenuItemContainer } from "./styles";
|
|
6
|
+
import type { TypeMenuItemProps } from "./MenuItemTypes";
|
|
7
|
+
|
|
8
|
+
export const MenuItem = ({
|
|
9
|
+
id,
|
|
10
|
+
children,
|
|
11
|
+
role = "menuitem",
|
|
12
|
+
label,
|
|
13
|
+
onClick,
|
|
14
|
+
onKeyDown,
|
|
15
|
+
disabled = false,
|
|
16
|
+
ref,
|
|
17
|
+
color,
|
|
18
|
+
...props
|
|
19
|
+
}: TypeMenuItemProps) => {
|
|
20
|
+
const { getItemProps, itemsMap, highlightedIndex, isListbox } =
|
|
21
|
+
useContext(MenuContext);
|
|
22
|
+
const item = (itemsMap[id] || { index: 0, id }) as TypeDefaultItemProps;
|
|
23
|
+
const highlighted = highlightedIndex === item?.index;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<MenuItemContainer
|
|
27
|
+
id={id}
|
|
28
|
+
tabIndex={role !== MENU_ITEM_ROLES.LISTITEM && !disabled ? -1 : undefined}
|
|
29
|
+
$highlighted={highlighted}
|
|
30
|
+
{...getItemProps?.({
|
|
31
|
+
...(isListbox ? {} : { role: "menuitem" }),
|
|
32
|
+
item,
|
|
33
|
+
index: item.index,
|
|
34
|
+
// This prop no longer supported in downshift
|
|
35
|
+
// disabled,
|
|
36
|
+
onClick,
|
|
37
|
+
onKeyDown,
|
|
38
|
+
})}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
</MenuItemContainer>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GetItemPropsOptions } from "downshift";
|
|
2
|
+
import type { TypeMenuItemRoles } from "../constants";
|
|
3
|
+
import type { TypeDefaultItemProps } from "../types";
|
|
4
|
+
import type {
|
|
5
|
+
TypeBorderSystemProps,
|
|
6
|
+
TypeColorSystemProps,
|
|
7
|
+
TypeFlexboxSystemProps,
|
|
8
|
+
TypeGridSystemProps,
|
|
9
|
+
TypeLayoutSystemProps,
|
|
10
|
+
TypePositionSystemProps,
|
|
11
|
+
TypeSpaceSystemProps,
|
|
12
|
+
TypeTypographySystemProps,
|
|
13
|
+
} from "@sproutsocial/seeds-react-system-props";
|
|
14
|
+
|
|
15
|
+
export interface TypeMenuItemStyledProps
|
|
16
|
+
extends TypeBorderSystemProps,
|
|
17
|
+
TypeColorSystemProps,
|
|
18
|
+
TypeFlexboxSystemProps,
|
|
19
|
+
TypeGridSystemProps,
|
|
20
|
+
TypeLayoutSystemProps,
|
|
21
|
+
TypePositionSystemProps,
|
|
22
|
+
TypeSpaceSystemProps,
|
|
23
|
+
TypeTypographySystemProps {}
|
|
24
|
+
|
|
25
|
+
export interface TypeMenuItemProps
|
|
26
|
+
extends TypeMenuItemStyledProps,
|
|
27
|
+
Omit<
|
|
28
|
+
GetItemPropsOptions<TypeDefaultItemProps>,
|
|
29
|
+
"as" | "item" | keyof TypeMenuItemStyledProps | keyof TypeDefaultItemProps
|
|
30
|
+
>,
|
|
31
|
+
Omit<TypeDefaultItemProps, "index"> {
|
|
32
|
+
label?: string;
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
role?: TypeMenuItemRoles;
|
|
36
|
+
$highlighted?: boolean;
|
|
37
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/* eslint-disable testing-library/no-unnecessary-act */
|
|
2
|
+
import {
|
|
3
|
+
screen,
|
|
4
|
+
render,
|
|
5
|
+
waitFor,
|
|
6
|
+
} from "@sproutsocial/seeds-react-testing-library";
|
|
7
|
+
import { PointerEventsCheckLevel } from "@testing-library/user-event";
|
|
8
|
+
import { theme } from "@sproutsocial/seeds-react-theme";
|
|
9
|
+
import {
|
|
10
|
+
MenuItem,
|
|
11
|
+
MenuToggleButton,
|
|
12
|
+
MenuContent,
|
|
13
|
+
ActionMenu,
|
|
14
|
+
} from "../../index";
|
|
15
|
+
import { TestWithMenuRoot } from "../../menuTestingUtils";
|
|
16
|
+
|
|
17
|
+
const menuItemProps = {
|
|
18
|
+
key: "book123",
|
|
19
|
+
id: "book-id",
|
|
20
|
+
children: "Book Title",
|
|
21
|
+
};
|
|
22
|
+
const testMenuContext = {
|
|
23
|
+
isOpen: true,
|
|
24
|
+
closeMenu: jest.fn(),
|
|
25
|
+
openMenu: jest.fn(),
|
|
26
|
+
getMenuProps: jest.fn((props) => props),
|
|
27
|
+
getToggleButtonProps: jest.fn((props) => ({
|
|
28
|
+
onKeyDown: jest.fn(),
|
|
29
|
+
...props,
|
|
30
|
+
})),
|
|
31
|
+
getItemProps: jest.fn((props) => ({ ...props })),
|
|
32
|
+
isListbox: false,
|
|
33
|
+
};
|
|
34
|
+
const testTriggerElement = <MenuToggleButton>Open</MenuToggleButton>;
|
|
35
|
+
|
|
36
|
+
describe("MenuItem User Behavior", () => {
|
|
37
|
+
describe("MenuItem is clickable", () => {
|
|
38
|
+
it("fires onClick event when clicked and not disabled", async () => {
|
|
39
|
+
const handleClick = jest.fn();
|
|
40
|
+
const { user } = render(
|
|
41
|
+
<TestWithMenuRoot
|
|
42
|
+
triggerElement={testTriggerElement}
|
|
43
|
+
{...testMenuContext}
|
|
44
|
+
>
|
|
45
|
+
<MenuContent>
|
|
46
|
+
<MenuItem {...menuItemProps} onClick={handleClick} />
|
|
47
|
+
</MenuContent>
|
|
48
|
+
</TestWithMenuRoot>
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
await user
|
|
52
|
+
.setup({
|
|
53
|
+
pointerEventsCheck: PointerEventsCheckLevel.Never,
|
|
54
|
+
})
|
|
55
|
+
.click(screen.getByRole("menuitem"));
|
|
56
|
+
|
|
57
|
+
await waitFor(() => expect(handleClick).toHaveBeenCalledTimes(1));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("MenuItem can be triggered via keyboard", () => {
|
|
62
|
+
it(`fires onClick event when enter and not disabled`, async () => {
|
|
63
|
+
const handleClick = jest.fn();
|
|
64
|
+
const handleStateChange = jest.fn();
|
|
65
|
+
|
|
66
|
+
const { user } = render(
|
|
67
|
+
<ActionMenu
|
|
68
|
+
triggerElement={testTriggerElement}
|
|
69
|
+
isOpen
|
|
70
|
+
onStateChange={handleStateChange}
|
|
71
|
+
>
|
|
72
|
+
<MenuContent>
|
|
73
|
+
<MenuItem
|
|
74
|
+
{...menuItemProps}
|
|
75
|
+
data-testid="menu-test"
|
|
76
|
+
key={0}
|
|
77
|
+
onKeyDown={handleClick}
|
|
78
|
+
tabIndex={0}
|
|
79
|
+
/>
|
|
80
|
+
</MenuContent>
|
|
81
|
+
</ActionMenu>
|
|
82
|
+
);
|
|
83
|
+
const targetElem = screen.getByTestId("menu-test");
|
|
84
|
+
|
|
85
|
+
await user.tab();
|
|
86
|
+
expect(targetElem).toHaveFocus();
|
|
87
|
+
|
|
88
|
+
await user.keyboard("{enter}");
|
|
89
|
+
|
|
90
|
+
await waitFor(() =>
|
|
91
|
+
expect(handleStateChange).toHaveBeenCalledWith({
|
|
92
|
+
isOpen: false,
|
|
93
|
+
type: "__togglebutton_keydown_enter__",
|
|
94
|
+
})
|
|
95
|
+
);
|
|
96
|
+
expect(handleClick).toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it(`fires onClick event when space and not disabled`, async () => {
|
|
99
|
+
const handleClick = jest.fn();
|
|
100
|
+
const handleStateChange = jest.fn();
|
|
101
|
+
|
|
102
|
+
const { user } = render(
|
|
103
|
+
<ActionMenu
|
|
104
|
+
triggerElement={testTriggerElement}
|
|
105
|
+
isOpen
|
|
106
|
+
onStateChange={handleStateChange}
|
|
107
|
+
>
|
|
108
|
+
<MenuContent>
|
|
109
|
+
<MenuItem
|
|
110
|
+
{...menuItemProps}
|
|
111
|
+
data-testid="menu-test"
|
|
112
|
+
key={0}
|
|
113
|
+
onKeyDown={handleClick}
|
|
114
|
+
tabIndex={0}
|
|
115
|
+
/>
|
|
116
|
+
</MenuContent>
|
|
117
|
+
</ActionMenu>
|
|
118
|
+
);
|
|
119
|
+
const targetElem = screen.getByTestId("menu-test");
|
|
120
|
+
|
|
121
|
+
await user.tab();
|
|
122
|
+
expect(targetElem).toHaveFocus();
|
|
123
|
+
|
|
124
|
+
await user.keyboard(" ");
|
|
125
|
+
|
|
126
|
+
await waitFor(() =>
|
|
127
|
+
expect(handleStateChange).toHaveBeenCalledWith({
|
|
128
|
+
isOpen: false,
|
|
129
|
+
type: "__togglebutton_keydown_space_button__",
|
|
130
|
+
})
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(handleClick).toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it(`does not fire onClick event when disabled`, async () => {
|
|
137
|
+
const handleClick = jest.fn();
|
|
138
|
+
const handleStateChange = jest.fn();
|
|
139
|
+
|
|
140
|
+
const books = [
|
|
141
|
+
{
|
|
142
|
+
index: 0,
|
|
143
|
+
id: "book-1",
|
|
144
|
+
author: "Harper Lee",
|
|
145
|
+
title: "To Kill a Mockingbird",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
index: 1,
|
|
149
|
+
id: "book-2",
|
|
150
|
+
author: "Lev Tolstoy",
|
|
151
|
+
title: "War and Peace",
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
const testItem = books[0];
|
|
156
|
+
|
|
157
|
+
const { user } = render(
|
|
158
|
+
<TestWithMenuRoot
|
|
159
|
+
triggerElement={<MenuToggleButton>Open</MenuToggleButton>}
|
|
160
|
+
{...{
|
|
161
|
+
...testMenuContext,
|
|
162
|
+
items: books,
|
|
163
|
+
selectedItem: testItem,
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
<MenuItem
|
|
167
|
+
id={testItem.id}
|
|
168
|
+
disabled={true}
|
|
169
|
+
onClick={handleStateChange}
|
|
170
|
+
>
|
|
171
|
+
{testItem.title}
|
|
172
|
+
</MenuItem>
|
|
173
|
+
</TestWithMenuRoot>
|
|
174
|
+
);
|
|
175
|
+
await user
|
|
176
|
+
.setup({
|
|
177
|
+
pointerEventsCheck: PointerEventsCheckLevel.Never,
|
|
178
|
+
})
|
|
179
|
+
.click(screen.getByRole("menuitem"));
|
|
180
|
+
|
|
181
|
+
expect(handleClick).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("MenuItem API", () => {
|
|
187
|
+
it("accepts children", () => {
|
|
188
|
+
render(
|
|
189
|
+
<ActionMenu
|
|
190
|
+
id="test-menu-item-selectable"
|
|
191
|
+
triggerElement={testTriggerElement}
|
|
192
|
+
isOpen
|
|
193
|
+
>
|
|
194
|
+
<MenuContent>
|
|
195
|
+
<MenuItem onClick={() => {}} id="test">
|
|
196
|
+
Hello world!
|
|
197
|
+
<span>testing</span>
|
|
198
|
+
</MenuItem>
|
|
199
|
+
</MenuContent>
|
|
200
|
+
</ActionMenu>
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const menuItem = screen.getByText("Hello world!");
|
|
204
|
+
const span = screen.getByText("testing");
|
|
205
|
+
expect(menuItem).toContainElement(span);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("accepts box & system Props", () => {
|
|
209
|
+
render(
|
|
210
|
+
<ActionMenu
|
|
211
|
+
id="test-menu-item-selectable"
|
|
212
|
+
triggerElement={testTriggerElement}
|
|
213
|
+
isOpen
|
|
214
|
+
>
|
|
215
|
+
<MenuContent>
|
|
216
|
+
<MenuItem display="flex" p={300} onClick={() => {}} id="test">
|
|
217
|
+
Hello world!
|
|
218
|
+
<span>testing</span>
|
|
219
|
+
</MenuItem>
|
|
220
|
+
</MenuContent>
|
|
221
|
+
</ActionMenu>
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const menuItem = screen.getByRole("menuitem");
|
|
225
|
+
expect(menuItem).toHaveStyle(`padding: ${theme.space[300]}`);
|
|
226
|
+
expect(menuItem).toHaveStyle(`display: flex`);
|
|
227
|
+
});
|
|
228
|
+
});
|