@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,56 @@
|
|
|
1
|
+
import { MenuItemSelectable } from "../MenuItemSelectable";
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
4
|
+
function MenuItemSelectableTypeTest() {
|
|
5
|
+
return (
|
|
6
|
+
<>
|
|
7
|
+
{/* Valid MenuItemSelectable with minimal required props */}
|
|
8
|
+
<MenuItemSelectable id="test">Test</MenuItemSelectable>
|
|
9
|
+
{/* Valid MenuItemSelectable allows active prop */}
|
|
10
|
+
<MenuItemSelectable id="test" active>
|
|
11
|
+
Test
|
|
12
|
+
</MenuItemSelectable>
|
|
13
|
+
{/* Valid MenuItemSelectable allows active prop false */}
|
|
14
|
+
<MenuItemSelectable id="test" active={false}>
|
|
15
|
+
Test
|
|
16
|
+
</MenuItemSelectable>
|
|
17
|
+
{/* Valid MenuItemSelectable allows inputType prop to be checkbox */}
|
|
18
|
+
<MenuItemSelectable id="test" inputType="checkbox">
|
|
19
|
+
Test
|
|
20
|
+
</MenuItemSelectable>
|
|
21
|
+
{/* Valid MenuItemSelectable allows inputType prop to be radio */}
|
|
22
|
+
<MenuItemSelectable id="test" inputType="radio">
|
|
23
|
+
Test
|
|
24
|
+
</MenuItemSelectable>
|
|
25
|
+
{/* Valid MenuItemSelectable allows inputType prop to be switch */}
|
|
26
|
+
<MenuItemSelectable id="test" inputType="switch">
|
|
27
|
+
Test
|
|
28
|
+
</MenuItemSelectable>
|
|
29
|
+
{/* Valid MenuItemSelectable allows inputType prop to be icon */}
|
|
30
|
+
<MenuItemSelectable id="test" inputType="icon">
|
|
31
|
+
Test
|
|
32
|
+
</MenuItemSelectable>
|
|
33
|
+
{/* Valid MenuItemSelectable allows inputLabel prop as a string */}
|
|
34
|
+
<MenuItemSelectable id="test" inputLabel="Test Label">
|
|
35
|
+
Test
|
|
36
|
+
</MenuItemSelectable>
|
|
37
|
+
|
|
38
|
+
{/* @ts-expect-error - no children is invalid */}
|
|
39
|
+
<MenuItemSelectable id="foo" />
|
|
40
|
+
{/* @ts-expect-error - missing id is invalid */}
|
|
41
|
+
<MenuItemSelectable>Test</MenuItemSelectable>
|
|
42
|
+
<MenuItemSelectable
|
|
43
|
+
/* @ts-expect-error - id may not be a number */
|
|
44
|
+
id={0}
|
|
45
|
+
/* @ts-expect-error - active may not be a string */
|
|
46
|
+
active="true"
|
|
47
|
+
/* @ts-expect-error - inputType must be valid */
|
|
48
|
+
inputType="invalid"
|
|
49
|
+
/* @ts-expect-error - inputLabel may not be a number */
|
|
50
|
+
inputLabel={0}
|
|
51
|
+
>
|
|
52
|
+
Test
|
|
53
|
+
</MenuItemSelectable>
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
|
|
3
|
+
export const MenuItemSelectableRow = styled.div`
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
pointer-events: none;
|
|
7
|
+
`;
|
|
8
|
+
|
|
9
|
+
export const MenuItemSelectableAction = styled.div`
|
|
10
|
+
margin-right: ${({ theme }) => theme.space[300]};
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
flex-shrink: 0;
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
export const MenuItemSelectableText = styled.div`
|
|
18
|
+
word-break: break-word;
|
|
19
|
+
flex-grow: 1;
|
|
20
|
+
min-width: 0;
|
|
21
|
+
`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text, type TypeTextProps } from "@sproutsocial/seeds-react-text";
|
|
3
|
+
import { useMenuContext } from "./MenuContext";
|
|
4
|
+
|
|
5
|
+
export const MenuLabel = ({ children, ...rest }: TypeTextProps) => {
|
|
6
|
+
const { getLabelProps } = useMenuContext();
|
|
7
|
+
return (
|
|
8
|
+
<Text as="label" color="text.body" {...getLabelProps?.()} {...rest}>
|
|
9
|
+
{children}
|
|
10
|
+
</Text>
|
|
11
|
+
);
|
|
12
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Feature: Menu Root
|
|
2
|
+
|
|
3
|
+
The main component responsible for setting up a Menu.
|
|
4
|
+
|
|
5
|
+
Rule: User Behavior
|
|
6
|
+
Background:
|
|
7
|
+
Given an end user is using the component
|
|
8
|
+
|
|
9
|
+
Scenario: MenuRoot closes when esc is pressed
|
|
10
|
+
Given a menu is open
|
|
11
|
+
When the esc key is pressed
|
|
12
|
+
Then the menu closes
|
|
13
|
+
|
|
14
|
+
Scenario Outline: MenuRoot focuses the correct element when opened
|
|
15
|
+
Given a MenuRoot <use case>
|
|
16
|
+
When the the menu is opened
|
|
17
|
+
Then <correct element> is in focus
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
| use case | the correct element |
|
|
21
|
+
| contains at least one MenuItem | the first MenuItem |
|
|
22
|
+
| has a custom defaultFocusRef | the defaultFocusRef element |
|
|
23
|
+
| has no MenuItems and no defaultFocusRef | an error is logged and the first focusable child |
|
|
24
|
+
|
|
25
|
+
Rule: API
|
|
26
|
+
Background:
|
|
27
|
+
Given an engineer is implementing the component
|
|
28
|
+
|
|
29
|
+
Scenario: MenuRoot is using keyboard instructions
|
|
30
|
+
Given a screen reader is being used
|
|
31
|
+
When a MenuRoot is provided with the keyboardInstructions prop
|
|
32
|
+
Then the screen reader will read the instructions
|
|
33
|
+
|
|
34
|
+
Scenario: MenuRoot is not using keyboard instructions
|
|
35
|
+
Given a screen reader is being used
|
|
36
|
+
When a MenuRoot is not provided with the keyboardInstructions prop
|
|
37
|
+
Then the screen reader will not read the instructions
|
|
38
|
+
|
|
39
|
+
Scenario Outline: MenuRoot contains children
|
|
40
|
+
When <child component(s)> provided
|
|
41
|
+
Then the MenuRoot should render the <component(s)>
|
|
42
|
+
|
|
43
|
+
Examples: child components
|
|
44
|
+
| child component(s) | component(s) |
|
|
45
|
+
| a MenuHeader is | MenuHeader |
|
|
46
|
+
| a MenuFooter is | MenuFooter |
|
|
47
|
+
| a MenuGroup is | MenuGroup |
|
|
48
|
+
| a Banner and MenuGroup are | Banner and MenuGroup |
|
|
49
|
+
| MenuItems wrapped in a Box are | MenuItems wrapped in a Box |
|
|
50
|
+
|
|
51
|
+
Scenario: MenuRoot implements MenuContext
|
|
52
|
+
When MenuContext props are provided
|
|
53
|
+
Then the MenuRoot passes those props to the MenuContext
|
|
54
|
+
|
|
55
|
+
Scenario Outline: MenuRoot implements all Downshift functions, props and utilities
|
|
56
|
+
When <input prop> from Downshift is provided
|
|
57
|
+
Then the MenuRoot passes <this prop> to the MenuContext
|
|
58
|
+
|
|
59
|
+
Examples: child components
|
|
60
|
+
| input prop | this prop |
|
|
61
|
+
| items | items |
|
|
62
|
+
| itemToString | itemToString |
|
|
63
|
+
| onSelectedItemChange | onSelectedItemChange |
|
|
64
|
+
| stateReducer | stateReducer |
|
|
65
|
+
| getToggleButtonProps | getToggleButtonProps |
|
|
66
|
+
| getItemProps | getItemProps |
|
|
67
|
+
| getLabelProps | getLabelProps |
|
|
68
|
+
| getMenuProps | getMenuProps |
|
|
69
|
+
| reset | reset |
|
|
70
|
+
| isOpen | isOpen |
|
|
71
|
+
|
|
72
|
+
Scenario: MenuRoot may be implemented without using Downshift
|
|
73
|
+
Given a developer is not using Downshift
|
|
74
|
+
When the MenuRoot is built using valid props
|
|
75
|
+
Then the MenuRoot passes those props to the MenuContext for custom implementation
|
|
76
|
+
|
|
77
|
+
Scenario Outline: MenuRoot children can access MenuContext
|
|
78
|
+
When child <component> accesses <menu context>
|
|
79
|
+
Then <menu context> is available to use
|
|
80
|
+
|
|
81
|
+
Examples: Prop Types
|
|
82
|
+
| component | menu context |
|
|
83
|
+
| Button | getToggleButtonProps |
|
|
84
|
+
| MenuFooter | closeMenu |
|
|
85
|
+
| Clear Link | reset |
|
|
86
|
+
|
|
87
|
+
Scenario Outline: MenuRoot checks its types
|
|
88
|
+
When <propName> is passed a value
|
|
89
|
+
Then MenuRoot expects it to be of type <propType>
|
|
90
|
+
|
|
91
|
+
Examples: Prop Types
|
|
92
|
+
| propName | propType |
|
|
93
|
+
| children | ReactNode |
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React, { useCallback, useRef } from "react";
|
|
2
|
+
import styled from "styled-components";
|
|
3
|
+
import { Popout, type TypePopoutProps } from "@sproutsocial/seeds-react-popout";
|
|
4
|
+
|
|
5
|
+
import { MenuProvider, type TypeMenuProviderProps } from "../MenuContext";
|
|
6
|
+
|
|
7
|
+
export interface TypeMenuRootOwnProps {
|
|
8
|
+
triggerElement: React.ReactElement<any>;
|
|
9
|
+
popoutProps?: Partial<
|
|
10
|
+
Omit<TypePopoutProps, "isOpen" | "setIsOpen" | "content" | "children">
|
|
11
|
+
>;
|
|
12
|
+
width?: TypePopoutProps["width"];
|
|
13
|
+
}
|
|
14
|
+
export interface TypeMenuRootProps
|
|
15
|
+
extends Partial<TypeMenuProviderProps>,
|
|
16
|
+
TypeMenuRootOwnProps {}
|
|
17
|
+
|
|
18
|
+
const CustomPopoutContent = styled(Popout.Content)`
|
|
19
|
+
padding: 0;
|
|
20
|
+
margin-left: 0;
|
|
21
|
+
margin-right: 0;
|
|
22
|
+
`;
|
|
23
|
+
export const MenuRoot = ({
|
|
24
|
+
children,
|
|
25
|
+
triggerElement,
|
|
26
|
+
popoutProps,
|
|
27
|
+
width = "300px",
|
|
28
|
+
...contextProps
|
|
29
|
+
}: TypeMenuRootProps) => {
|
|
30
|
+
const { isListbox, isOpen, openMenu, closeMenu, getMenuProps } = contextProps;
|
|
31
|
+
const setIsOpen = useCallback(
|
|
32
|
+
(newIsOpen: boolean) => {
|
|
33
|
+
newIsOpen ? openMenu?.() : closeMenu?.();
|
|
34
|
+
},
|
|
35
|
+
[openMenu, closeMenu]
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<MenuProvider {...contextProps}>
|
|
40
|
+
<Popout
|
|
41
|
+
isOpen={!!isOpen}
|
|
42
|
+
setIsOpen={setIsOpen}
|
|
43
|
+
content={
|
|
44
|
+
<CustomPopoutContent width={width}>
|
|
45
|
+
<div
|
|
46
|
+
{...getMenuProps?.(
|
|
47
|
+
{
|
|
48
|
+
...(isListbox ? {} : { role: "menu" }),
|
|
49
|
+
},
|
|
50
|
+
// Suppress ref error for now
|
|
51
|
+
{ suppressRefError: true }
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{children}
|
|
55
|
+
</div>
|
|
56
|
+
</CustomPopoutContent>
|
|
57
|
+
}
|
|
58
|
+
focusLockProps={{
|
|
59
|
+
// correct jerking motion when scrolling while the Popout is open
|
|
60
|
+
// returnFocus: false,
|
|
61
|
+
crossFrame: false,
|
|
62
|
+
}}
|
|
63
|
+
disableWrapperAria
|
|
64
|
+
{...popoutProps}
|
|
65
|
+
>
|
|
66
|
+
{triggerElement}
|
|
67
|
+
</Popout>
|
|
68
|
+
</MenuProvider>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import React, { useContext, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
} from "@sproutsocial/seeds-react-testing-library";
|
|
7
|
+
import { Box } from "@sproutsocial/seeds-react-box";
|
|
8
|
+
import { Button } from "@sproutsocial/seeds-react-button";
|
|
9
|
+
import {
|
|
10
|
+
MenuRoot,
|
|
11
|
+
MenuToggleButton,
|
|
12
|
+
MenuItem,
|
|
13
|
+
MenuContext,
|
|
14
|
+
MenuFooter,
|
|
15
|
+
MenuHeader,
|
|
16
|
+
type TypeMenuContextProps,
|
|
17
|
+
} from "../../index";
|
|
18
|
+
|
|
19
|
+
jest.mock("@sproutsocial/seeds-react-portal", () => ({ children }) => (
|
|
20
|
+
<div>{children}</div>
|
|
21
|
+
));
|
|
22
|
+
|
|
23
|
+
const TestComponent = () => {
|
|
24
|
+
const { isOpen } = useContext(MenuContext);
|
|
25
|
+
return <div>{isOpen ? "Is open" : "Is closed"}</div>;
|
|
26
|
+
};
|
|
27
|
+
const TestToggleButton = () => {
|
|
28
|
+
const { getToggleButtonProps } = useContext(MenuContext);
|
|
29
|
+
return <Button {...getToggleButtonProps?.()}>Toggle</Button>;
|
|
30
|
+
};
|
|
31
|
+
const TestMenuFooter = () => {
|
|
32
|
+
const { closeMenu } = useContext(MenuContext);
|
|
33
|
+
return (
|
|
34
|
+
<MenuFooter>
|
|
35
|
+
<Button onClick={() => closeMenu?.()}>Close</Button>
|
|
36
|
+
</MenuFooter>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
// const TestClearLink = () => {
|
|
40
|
+
// const { reset } = useContext(MenuContext);
|
|
41
|
+
// return <Link onClick={() => reset?.()}>Reset</Link>;
|
|
42
|
+
// };
|
|
43
|
+
|
|
44
|
+
describe("MenuRoot", () => {
|
|
45
|
+
// TODO: test this on the Menu components that use downshift?
|
|
46
|
+
// it("closes when esc is pressed", () => {
|
|
47
|
+
// // closes when esc is pressed
|
|
48
|
+
// });
|
|
49
|
+
|
|
50
|
+
// TODO: write these tests when focus logic is implemented
|
|
51
|
+
// test.each([
|
|
52
|
+
// {
|
|
53
|
+
// element: "focuses the first MenuItem",
|
|
54
|
+
// condition: "contains at least one MenuItem",
|
|
55
|
+
// },
|
|
56
|
+
// {
|
|
57
|
+
// element: "focuses the defaultFocusRef element",
|
|
58
|
+
// condition: "has a custom defaultFocusRef",
|
|
59
|
+
// },
|
|
60
|
+
// {
|
|
61
|
+
// element: "an error is logged and focuses the first focusable child",
|
|
62
|
+
// condition: "has no MenuItems and no defaultFocusRef",
|
|
63
|
+
// },
|
|
64
|
+
// ])("$element when opened if it $condition", ({}) => {
|
|
65
|
+
// // focuses the correct element when opened
|
|
66
|
+
// });
|
|
67
|
+
|
|
68
|
+
// TODO: write tests for keyboard instructions when feature added
|
|
69
|
+
// it("triggers keyboard instructions", () => {
|
|
70
|
+
// // triggers keyboard instructions
|
|
71
|
+
// });
|
|
72
|
+
|
|
73
|
+
// it("has no keyboard instructions by default", () => {
|
|
74
|
+
// // has no keyboard instructions by default
|
|
75
|
+
// });
|
|
76
|
+
|
|
77
|
+
// TODO: Add MenuGroup when the component is ready
|
|
78
|
+
it("renders custom children", () => {
|
|
79
|
+
render(
|
|
80
|
+
<MenuRoot
|
|
81
|
+
triggerElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
|
|
82
|
+
isOpen
|
|
83
|
+
>
|
|
84
|
+
<MenuHeader title="Menu Header" />
|
|
85
|
+
<div>Custom Banner</div>
|
|
86
|
+
<Box>
|
|
87
|
+
<MenuItem id="1">Item 1</MenuItem>
|
|
88
|
+
</Box>
|
|
89
|
+
<MenuFooter>Menu Footer</MenuFooter>
|
|
90
|
+
</MenuRoot>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(screen.getByText("Menu Header")).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByText("Custom Banner")).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
|
96
|
+
expect(screen.getByText("Menu Footer")).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// TODO: test this using the SingleSelectMenu, MultiSelectMenu, and ComboBox
|
|
100
|
+
// it("passes downshift props to the MenuContext", () => {
|
|
101
|
+
// // passes downshift props to the MenuContext
|
|
102
|
+
// // Can be tested by the components that use downshift
|
|
103
|
+
// });
|
|
104
|
+
|
|
105
|
+
it("can be implemented without using Downshift", () => {
|
|
106
|
+
const randomText = "Some random text";
|
|
107
|
+
const TestCustomMenu = () => {
|
|
108
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
109
|
+
const getToggleButtonProps = (props) => ({
|
|
110
|
+
onClick: () => setIsOpen(!isOpen),
|
|
111
|
+
...props,
|
|
112
|
+
});
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
114
|
+
// @ts-ignore - mock contextProps may be imperfect
|
|
115
|
+
const contextProps: TypeMenuContextProps = {
|
|
116
|
+
toggleMenu: () => setIsOpen(!isOpen),
|
|
117
|
+
getToggleButtonProps,
|
|
118
|
+
isOpen,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<MenuRoot
|
|
123
|
+
triggerElement={
|
|
124
|
+
<MenuToggleButton appearance="secondary">
|
|
125
|
+
Open Menu (<TestComponent />)
|
|
126
|
+
</MenuToggleButton>
|
|
127
|
+
}
|
|
128
|
+
{...contextProps}
|
|
129
|
+
>
|
|
130
|
+
{randomText}
|
|
131
|
+
</MenuRoot>
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
render(<TestCustomMenu />);
|
|
135
|
+
|
|
136
|
+
expect(screen.getByText("Is closed")).toBeInTheDocument();
|
|
137
|
+
expect(screen.queryByText(randomText)).not.toBeInTheDocument();
|
|
138
|
+
fireEvent.click(screen.getByRole("button"));
|
|
139
|
+
expect(screen.getByText("Is open")).toBeInTheDocument();
|
|
140
|
+
expect(screen.getByText(randomText)).toBeInTheDocument();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const contextTests: {
|
|
144
|
+
child: string;
|
|
145
|
+
TestComponent: React.FC;
|
|
146
|
+
contextProps: Partial<TypeMenuContextProps>;
|
|
147
|
+
testBtnText: string;
|
|
148
|
+
testFn: string;
|
|
149
|
+
}[] = [
|
|
150
|
+
{
|
|
151
|
+
child: "Button",
|
|
152
|
+
TestComponent: TestToggleButton,
|
|
153
|
+
contextProps: {
|
|
154
|
+
// @ts-expect-error - mocked function doesn't match expected type
|
|
155
|
+
getToggleButtonProps: jest.fn((props) => ({
|
|
156
|
+
onKeyDown: jest.fn() as React.KeyboardEventHandler,
|
|
157
|
+
...props,
|
|
158
|
+
})),
|
|
159
|
+
},
|
|
160
|
+
testBtnText: "Toggle",
|
|
161
|
+
testFn: "getToggleButtonProps",
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
child: "MenuFooter",
|
|
165
|
+
TestComponent: TestMenuFooter,
|
|
166
|
+
contextProps: { closeMenu: jest.fn() },
|
|
167
|
+
testBtnText: "Close",
|
|
168
|
+
testFn: "closeMenu",
|
|
169
|
+
},
|
|
170
|
+
// TODO: Add this back when Link is abstracted
|
|
171
|
+
// {
|
|
172
|
+
// child: "Clear Link",
|
|
173
|
+
// TestComponent: TestClearLink,
|
|
174
|
+
// contextProps: { reset: jest.fn() },
|
|
175
|
+
// testBtnText: "Reset",
|
|
176
|
+
// testFn: "reset",
|
|
177
|
+
// },
|
|
178
|
+
];
|
|
179
|
+
test.each(contextTests)(
|
|
180
|
+
"$child can access $testFn from the MenuContext",
|
|
181
|
+
({ TestComponent, contextProps, testBtnText, testFn }) => {
|
|
182
|
+
render(
|
|
183
|
+
<MenuRoot
|
|
184
|
+
triggerElement={<MenuToggleButton>Open Menu</MenuToggleButton>}
|
|
185
|
+
isOpen
|
|
186
|
+
{...contextProps}
|
|
187
|
+
>
|
|
188
|
+
<TestComponent />
|
|
189
|
+
</MenuRoot>
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const testBtn = screen.getByRole("button", { name: testBtnText });
|
|
193
|
+
fireEvent.click(testBtn);
|
|
194
|
+
expect(contextProps[testFn]).toHaveBeenCalled();
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { MenuRoot, MenuContent, MenuHeader, MenuFooter } from "../../index";
|
|
3
|
+
|
|
4
|
+
export const MenuRootTypeTest = () => (
|
|
5
|
+
<>
|
|
6
|
+
{/* Valid MenuRoot with minimal required props */}
|
|
7
|
+
<MenuRoot triggerElement={<>test</>}>Test</MenuRoot>
|
|
8
|
+
{/* Valid MenuRoot allows MenuContent by itself as a child */}
|
|
9
|
+
<MenuRoot triggerElement={<>test</>}>
|
|
10
|
+
<MenuContent>Test</MenuContent>
|
|
11
|
+
</MenuRoot>
|
|
12
|
+
{/* Valid MenuRoot allows multiple children without fragment */}
|
|
13
|
+
<MenuRoot triggerElement={<>test</>}>
|
|
14
|
+
<MenuHeader>Test</MenuHeader>
|
|
15
|
+
<MenuContent>Test</MenuContent>
|
|
16
|
+
<MenuFooter>Test</MenuFooter>
|
|
17
|
+
</MenuRoot>
|
|
18
|
+
{/* Valid MenuRoot accepts Downshift props and functions for useSelect */}
|
|
19
|
+
<MenuRoot
|
|
20
|
+
triggerElement={<>test</>}
|
|
21
|
+
onSelectedItemChange={() => {}}
|
|
22
|
+
selectItem={() => {}}
|
|
23
|
+
selectedItem={null}
|
|
24
|
+
stateReducer={(c) => c}
|
|
25
|
+
onStateChange={() => {}}
|
|
26
|
+
getA11yStatusMessage={() => ""}
|
|
27
|
+
isOpen
|
|
28
|
+
>
|
|
29
|
+
Test
|
|
30
|
+
</MenuRoot>
|
|
31
|
+
{/* Valid MenuRoot accepts Downshift props and functions for useMultipleSelection */}
|
|
32
|
+
<MenuRoot
|
|
33
|
+
triggerElement={<>test</>}
|
|
34
|
+
onSelectedItemsChange={() => {}}
|
|
35
|
+
selectedItems={[]}
|
|
36
|
+
multiSelectStateReducer={(c) => c}
|
|
37
|
+
onMultiSelectStateChange={() => {}}
|
|
38
|
+
getA11yMultiStatusMessage={() => ""}
|
|
39
|
+
>
|
|
40
|
+
Test
|
|
41
|
+
</MenuRoot>
|
|
42
|
+
|
|
43
|
+
{/* @ts-expect-error - triggerElement is a required prop */}
|
|
44
|
+
<MenuRoot>Test</MenuRoot>
|
|
45
|
+
<MenuRoot
|
|
46
|
+
/* @ts-expect-error - triggerElement must be a valid react element */
|
|
47
|
+
triggerElement="test"
|
|
48
|
+
>
|
|
49
|
+
Test
|
|
50
|
+
</MenuRoot>
|
|
51
|
+
{/* @ts-expect-error - when no children provided */}
|
|
52
|
+
<MenuRoot triggerElement="test" />
|
|
53
|
+
|
|
54
|
+
<MenuRoot
|
|
55
|
+
triggerElement={<>test</>}
|
|
56
|
+
/* @ts-expect-error - isOpen can't be a string */
|
|
57
|
+
isOpen="false"
|
|
58
|
+
/* @ts-expect-error - onSelectedItemChange can't be a string */
|
|
59
|
+
onSelectedItemChange="test"
|
|
60
|
+
/* @ts-expect-error - selectItem can't be a string */
|
|
61
|
+
selectItem="test"
|
|
62
|
+
/* @ts-expect-error - selectedItem can't be a string */
|
|
63
|
+
selectedItem="test"
|
|
64
|
+
/* @ts-expect-error - stateReducer must have a return value */
|
|
65
|
+
stateReducer={() => {}}
|
|
66
|
+
/* @ts-expect-error - onMultiSelectStateChange can't be a string */
|
|
67
|
+
onMultiSelectStateChange="test"
|
|
68
|
+
/* @ts-expect-error - selectedItems can't be a string */
|
|
69
|
+
selectedItems="test"
|
|
70
|
+
/* @ts-expect-error - multiSelectStateReducer must have a return value */
|
|
71
|
+
multiSelectStateReducer="test"
|
|
72
|
+
>
|
|
73
|
+
Test
|
|
74
|
+
</MenuRoot>
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./MenuRoot";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React, { type ReactNode, useContext } from "react";
|
|
2
|
+
import { Button, type TypeButtonProps } from "@sproutsocial/seeds-react-button";
|
|
3
|
+
import { MenuContext } from "./MenuContext";
|
|
4
|
+
|
|
5
|
+
export interface TypeMenuToggleButtonProps extends Partial<TypeButtonProps> {
|
|
6
|
+
children: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const MenuToggleButton = ({
|
|
10
|
+
children,
|
|
11
|
+
...rest
|
|
12
|
+
}: TypeMenuToggleButtonProps) => {
|
|
13
|
+
const { getToggleButtonProps, isListbox, getDropdownProps } =
|
|
14
|
+
useContext(MenuContext);
|
|
15
|
+
return (
|
|
16
|
+
<Button
|
|
17
|
+
appearance="secondary"
|
|
18
|
+
{...getToggleButtonProps?.({
|
|
19
|
+
// Decide if we want the preventKeyAction stuff. Maybe create a prop to allow for this to be overriden.
|
|
20
|
+
...getDropdownProps?.({ preventKeyAction: true }),
|
|
21
|
+
refKey: "innerRef",
|
|
22
|
+
...(isListbox ? {} : { role: "button", "aria-haspopup": "menu" }),
|
|
23
|
+
})}
|
|
24
|
+
{...rest}
|
|
25
|
+
>
|
|
26
|
+
{children}
|
|
27
|
+
</Button>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Feature: MultiSelectMenu
|
|
2
|
+
|
|
3
|
+
A menu component that allows users to select multiple items from a list.
|
|
4
|
+
|
|
5
|
+
Rule: User behavior
|
|
6
|
+
Background:
|
|
7
|
+
Given an end user is using the component
|
|
8
|
+
|
|
9
|
+
Scenario: User can select multiple items
|
|
10
|
+
Given the MultiSelectMenu is open
|
|
11
|
+
When an item is selected
|
|
12
|
+
Then the items are marked as selected
|
|
13
|
+
And the menu remains open
|
|
14
|
+
|
|
15
|
+
Scenario: Menu retains selection state
|
|
16
|
+
Given the MultiSelectMenu has items selected
|
|
17
|
+
When the MultiSelectMenu is opened
|
|
18
|
+
Then the items appear as selected
|
|
19
|
+
|
|
20
|
+
Scenario: Screen reader announces listbox
|
|
21
|
+
Given a screen reader is being used
|
|
22
|
+
When the MultiSelect Input is focused
|
|
23
|
+
Then the screen reader announces it as a "listbox" with multiple selections possible
|
|
24
|
+
|
|
25
|
+
Scenario: Multi-select is navigable via keyboard
|
|
26
|
+
Given the MultiSelectMenu is focused
|
|
27
|
+
And an item is selected with 'spacebar' or 'enter'
|
|
28
|
+
Then the onSelectedItemsChange callback triggers
|
|
29
|
+
|
|
30
|
+
Rule: API
|
|
31
|
+
Background:
|
|
32
|
+
Given an engineer is developing using the component
|
|
33
|
+
|
|
34
|
+
Scenario: Component accepts selected items
|
|
35
|
+
When a list of selected items is passed to the MultiSelectMenu
|
|
36
|
+
Then the items are displayed as selected
|
|
37
|
+
|
|
38
|
+
Scenario: Component handles existing selection state
|
|
39
|
+
When the MultiSelectMenu is passed a `selectedItems` prop
|
|
40
|
+
Then the component renders the existing values
|
|
41
|
+
|
|
42
|
+
Scenario: Component handles new selection state
|
|
43
|
+
When the MultiSelectMenu is not passed
|
|
44
|
+
And a new selection is created
|
|
45
|
+
Then the 'onSelectedItemsChangee' callback is fired
|
|
46
|
+
|
|
47
|
+
Scenario: Menu does not close on selection
|
|
48
|
+
When an item is selected
|
|
49
|
+
Then the 'onSelectedItemsChange' callback fires without closing the menu
|
|
50
|
+
|
|
51
|
+
Scenario Outline: Actions can be triggered from the MultiSelectMenu
|
|
52
|
+
When actionable elements are provided to the MultiSelectMenu
|
|
53
|
+
And an `<action>` is clicked
|
|
54
|
+
Then the corresponding `onClick` should be executed
|
|
55
|
+
|
|
56
|
+
Examples: Action Types
|
|
57
|
+
| action |
|
|
58
|
+
| ---------------- |
|
|
59
|
+
| select button |
|
|
60
|
+
| deselect button |
|
|
61
|
+
|
|
62
|
+
Scenario Outline: MultiSelectMenu checks its types
|
|
63
|
+
When `<propName>` is passed a value
|
|
64
|
+
Then MultiSelectMenu expects it to be of type `<propType>`
|
|
65
|
+
|
|
66
|
+
Examples: Prop Types
|
|
67
|
+
| propName | propType |
|
|
68
|
+
| --------------------- | ------------------------ |
|
|
69
|
+
| selectedItems | array |
|
|
70
|
+
| onSelectedItemsChange | function |
|