@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,116 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useMemo,
|
|
4
|
+
useContext,
|
|
5
|
+
type RefObject,
|
|
6
|
+
} from "react";
|
|
7
|
+
import type {
|
|
8
|
+
UseSelectReturnValue,
|
|
9
|
+
UseSelectProps,
|
|
10
|
+
UseMultipleSelectionReturnValue,
|
|
11
|
+
UseMultipleSelectionProps,
|
|
12
|
+
} from "downshift";
|
|
13
|
+
import type { TypeDefaultItemProps, TypeNotEmptyChildren } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface TypeDownshiftMultiSelectProps
|
|
16
|
+
extends Partial<UseSelectProps<TypeDefaultItemProps>>,
|
|
17
|
+
Partial<
|
|
18
|
+
Omit<
|
|
19
|
+
UseMultipleSelectionProps<TypeDefaultItemProps>,
|
|
20
|
+
"stateReducer" | "onStateChange" | "getA11yStatusMessage"
|
|
21
|
+
>
|
|
22
|
+
> {
|
|
23
|
+
// Multi Select Props with conflicting useSelect prop names
|
|
24
|
+
getA11yMultiStatusMessage?: UseMultipleSelectionProps<TypeDefaultItemProps>["getA11yStatusMessage"];
|
|
25
|
+
onMultiSelectStateChange?: UseMultipleSelectionProps<TypeDefaultItemProps>["onStateChange"];
|
|
26
|
+
multiSelectStateReducer?: UseMultipleSelectionProps<TypeDefaultItemProps>["stateReducer"];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TypeMenuContextProps
|
|
30
|
+
extends Partial<UseSelectReturnValue<TypeDefaultItemProps>>,
|
|
31
|
+
Partial<UseMultipleSelectionReturnValue<TypeDefaultItemProps>>,
|
|
32
|
+
TypeDownshiftMultiSelectProps {
|
|
33
|
+
items: TypeDefaultItemProps[];
|
|
34
|
+
isListbox?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TypeMenuContext extends TypeMenuContextProps {
|
|
38
|
+
itemsMap: { [key: string]: TypeDefaultItemProps };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TypeMenuProviderProps extends Partial<TypeMenuContextProps> {
|
|
42
|
+
children: TypeNotEmptyChildren;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Object for Initializing the Menu Context
|
|
47
|
+
*/
|
|
48
|
+
const defaultMenuContext: TypeMenuContext = {
|
|
49
|
+
items: [],
|
|
50
|
+
itemsMap: {},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Hook for initializing Menu context
|
|
55
|
+
*/
|
|
56
|
+
export const useMenu = ({
|
|
57
|
+
items = [],
|
|
58
|
+
isListbox = false,
|
|
59
|
+
isOpen = false,
|
|
60
|
+
...rest
|
|
61
|
+
}: Partial<TypeMenuContextProps> = {}): TypeMenuContext => {
|
|
62
|
+
const menuProps = useMemo(() => ({ ...rest }), [rest]);
|
|
63
|
+
|
|
64
|
+
const itemsMap = useMemo(
|
|
65
|
+
() =>
|
|
66
|
+
items.reduce((map, item) => {
|
|
67
|
+
map[`${item.id}`] = item;
|
|
68
|
+
return map;
|
|
69
|
+
}, {} as TypeMenuContext["itemsMap"]),
|
|
70
|
+
[items]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
isOpen,
|
|
75
|
+
isListbox,
|
|
76
|
+
items,
|
|
77
|
+
itemsMap,
|
|
78
|
+
...menuProps,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* MenuContext factory, sets the type and the initial object
|
|
84
|
+
*/
|
|
85
|
+
export const MenuContext = createContext<TypeMenuContext>(defaultMenuContext);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Provider Component sets the context values into the provider
|
|
89
|
+
*/
|
|
90
|
+
export const MenuProvider = ({
|
|
91
|
+
children,
|
|
92
|
+
...overrides
|
|
93
|
+
}: TypeMenuProviderProps) => {
|
|
94
|
+
const filterContext = useMenu(overrides);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<MenuContext.Provider value={{ ...filterContext }}>
|
|
98
|
+
{children}
|
|
99
|
+
</MenuContext.Provider>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Utility hook for consuming MenuContext
|
|
105
|
+
*/
|
|
106
|
+
export const useMenuContext = (): TypeMenuContext => {
|
|
107
|
+
const menuContextValue = useContext(MenuContext);
|
|
108
|
+
|
|
109
|
+
if (menuContextValue === null) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"useMenuContext must be used within a <MenuContext.Provider />"
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return menuContextValue;
|
|
116
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Feature: Menu Footer
|
|
2
|
+
|
|
3
|
+
The bottom area of a Menu.
|
|
4
|
+
|
|
5
|
+
Rule: API
|
|
6
|
+
Background:
|
|
7
|
+
Given an engineer is implementing a MenuFooter
|
|
8
|
+
|
|
9
|
+
Scenario Outline: MenuFooter contains children
|
|
10
|
+
When <child component(s)> provided
|
|
11
|
+
Then the MenuFooter should render the <component(s)>
|
|
12
|
+
|
|
13
|
+
Examples: child components
|
|
14
|
+
| child component(s) | component(s) |
|
|
15
|
+
| a Link is | Link |
|
|
16
|
+
| a Switch is | Switch |
|
|
17
|
+
| a Button is | Button |
|
|
18
|
+
| a Link and a Button are | Link and Button |
|
|
19
|
+
|
|
20
|
+
Scenario: MenuFooter extends Box
|
|
21
|
+
When Box features are used
|
|
22
|
+
Then the MenuFooter responds as a Box would
|
|
23
|
+
|
|
24
|
+
Scenario Outline: MenuFooter checks its types
|
|
25
|
+
When <propName> is passed a value
|
|
26
|
+
Then MenuFooter expects it to be of type <propType>
|
|
27
|
+
|
|
28
|
+
Examples: Prop Types
|
|
29
|
+
| propName | propType |
|
|
30
|
+
| children | ReactNode |
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { TypeMenuFooterProps } from "./MenuFooterTypes";
|
|
3
|
+
import { StyledMenuFooterBox } from "./styles";
|
|
4
|
+
|
|
5
|
+
export const MenuFooter = ({ children, ...rest }: TypeMenuFooterProps) => {
|
|
6
|
+
return <StyledMenuFooterBox {...rest}>{children}</StyledMenuFooterBox>;
|
|
7
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { TypeBoxProps as TypeMenuFooterProps } from "@sproutsocial/seeds-react-box";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
2
|
+
import { MenuFooter, MenuRoot } from "../../index";
|
|
3
|
+
import { Button } from "@sproutsocial/seeds-react-button";
|
|
4
|
+
import { Switch } from "@sproutsocial/seeds-react-switch";
|
|
5
|
+
|
|
6
|
+
describe("MenuFooter API", () => {
|
|
7
|
+
const contextTests = [
|
|
8
|
+
{
|
|
9
|
+
child: "Button",
|
|
10
|
+
TestComponent: <Button role="button">Find me!</Button>,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
child: "Switcher",
|
|
14
|
+
TestComponent: (
|
|
15
|
+
<Switch
|
|
16
|
+
role="button"
|
|
17
|
+
checked={false}
|
|
18
|
+
onClick={() => {}}
|
|
19
|
+
aria-label="Find me!"
|
|
20
|
+
/>
|
|
21
|
+
),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
child: "Link",
|
|
25
|
+
TestComponent: <button role="button">Find me!</button>,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
it.each(contextTests)(
|
|
30
|
+
"$child contains children",
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
32
|
+
({ child, TestComponent }) => {
|
|
33
|
+
render(
|
|
34
|
+
<MenuFooter>
|
|
35
|
+
Hello world!
|
|
36
|
+
{TestComponent}
|
|
37
|
+
</MenuFooter>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const testBtn = screen.getByRole("button");
|
|
41
|
+
|
|
42
|
+
const parentComponent = screen.getByText("Hello world!");
|
|
43
|
+
expect(parentComponent).toContainElement(testBtn);
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
it.each(contextTests)(
|
|
48
|
+
"$child contains children when in MenuRoot",
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
50
|
+
({ child, TestComponent }) => {
|
|
51
|
+
render(
|
|
52
|
+
<MenuRoot
|
|
53
|
+
isOpen
|
|
54
|
+
triggerElement={<Button onClick={jest.fn()}>Open</Button>}
|
|
55
|
+
>
|
|
56
|
+
<MenuFooter>
|
|
57
|
+
Hello world!
|
|
58
|
+
{TestComponent}
|
|
59
|
+
</MenuFooter>
|
|
60
|
+
</MenuRoot>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const testBtn = screen.getByRole("button", { name: "Find me!" });
|
|
64
|
+
|
|
65
|
+
const parentComponent = screen.getByText("Hello world!");
|
|
66
|
+
expect(parentComponent).toContainElement(testBtn);
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
it("extends Box", () => {
|
|
71
|
+
render(<MenuFooter bg="#ff0000">Hello world!</MenuFooter>);
|
|
72
|
+
|
|
73
|
+
const menuFooter = screen.getByText("Hello world!");
|
|
74
|
+
expect(menuFooter).toHaveStyle("background: #ff0000");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { MenuFooter } from "../MenuFooter";
|
|
2
|
+
|
|
3
|
+
export const MenuFooterTypeTest = () => (
|
|
4
|
+
<>
|
|
5
|
+
<MenuFooter>Test...</MenuFooter>
|
|
6
|
+
{/* @ts-expect-error - test that invalid flexDirection is rejected */}
|
|
7
|
+
<MenuFooter flexDirection="invalid" />
|
|
8
|
+
{/* @ts-expect-error - test that invalid type is rejected */}
|
|
9
|
+
<MenuFooter type="invalid" />
|
|
10
|
+
<MenuFooter gap={100}>Test...</MenuFooter>
|
|
11
|
+
<MenuFooter gap={1} rowGap={400} columnGap={999} />;
|
|
12
|
+
<MenuFooter gap="string" rowGap="string" columnGap="string" />;
|
|
13
|
+
<MenuFooter
|
|
14
|
+
gap={[1, "string"]}
|
|
15
|
+
rowGap={[1, "string"]}
|
|
16
|
+
columnGap={[1, "string"]}
|
|
17
|
+
/>
|
|
18
|
+
;
|
|
19
|
+
</>
|
|
20
|
+
);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Feature: Menu Group
|
|
2
|
+
|
|
3
|
+
The component responsible for grouping items for a Menu.
|
|
4
|
+
|
|
5
|
+
Rule: User Behavior
|
|
6
|
+
Background:
|
|
7
|
+
Given an end user is using the component
|
|
8
|
+
|
|
9
|
+
Scenario: MenuGroup is accessible to screen readers when menu opened
|
|
10
|
+
Given a screen reader is being used
|
|
11
|
+
When a menu is opened
|
|
12
|
+
And the first group receives focus
|
|
13
|
+
Then the first group is announced by the screen reader
|
|
14
|
+
|
|
15
|
+
Scenario: MenuGroup is accessible to screen readers when there are 2 groups
|
|
16
|
+
Given a screen reader is being used
|
|
17
|
+
When a navigating between two groups
|
|
18
|
+
Then the new group is announced by the screen reader
|
|
19
|
+
|
|
20
|
+
Scenario Outline: MenuGroup uses select type
|
|
21
|
+
When the MenuGroup has <group type>
|
|
22
|
+
And MenuContext is set to <menu type>
|
|
23
|
+
Then <selection behavior>
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
| group type | menu type | selection behavior |
|
|
27
|
+
| single | single | the menu uses the default behavior |
|
|
28
|
+
| multiple | multiple | the menu uses the default behavior |
|
|
29
|
+
| single | multiple | the group behaves like a single select |
|
|
30
|
+
| multiple | single | the group throws an error and uses default behavior |
|
|
31
|
+
| default | single | the menu uses the default behavior |
|
|
32
|
+
| default | multiple | the menu uses the default behavior |
|
|
33
|
+
|
|
34
|
+
Rule: API
|
|
35
|
+
Background:
|
|
36
|
+
Given an engineer is implementing the component
|
|
37
|
+
|
|
38
|
+
Scenario: MenuGroup has a title
|
|
39
|
+
When the "title" prop is set
|
|
40
|
+
Then the title is visible at the top of the group
|
|
41
|
+
|
|
42
|
+
Scenario: MenuGroup has no title
|
|
43
|
+
When the "title" prop is not set
|
|
44
|
+
Then no title is visible at the top of the group
|
|
45
|
+
|
|
46
|
+
Scenario: MenuGroup has no items provided
|
|
47
|
+
When no children are provided to MenuGroup
|
|
48
|
+
And no items prop is provided
|
|
49
|
+
Then the MenuGroup will show a warning in the console
|
|
50
|
+
And will not render the empty group
|
|
51
|
+
|
|
52
|
+
Scenario: MenuGroup can take items as children
|
|
53
|
+
When MenuItem components are passed as children to a MenuGroup
|
|
54
|
+
Then the MenuGroup will render them inside of the group
|
|
55
|
+
|
|
56
|
+
Scenario: MenuGroup items prop might be empty
|
|
57
|
+
Given a list of items is empty
|
|
58
|
+
When that list of items is provided to the items prop on a MenuGroup
|
|
59
|
+
Then the MenuGroup will show a warning in the console
|
|
60
|
+
And will not render the empty group
|
|
61
|
+
|
|
62
|
+
Scenario: MenuGroup can take items via a prop
|
|
63
|
+
Given a list of items is not empty
|
|
64
|
+
And the items are a valid object shape
|
|
65
|
+
When that list of items is provided to the items prop on a MenuGroup
|
|
66
|
+
Then the MenuGroup will render the items using the MenuItem component
|
|
67
|
+
|
|
68
|
+
Scenario: MenuGroup can use the items prop with a custom MenuItem
|
|
69
|
+
Given a list of items is not empty
|
|
70
|
+
And the items are a valid object shape
|
|
71
|
+
When that list of items is provided to the items prop on a MenuGroup
|
|
72
|
+
And a valid component is provided to the customMenuItem prop
|
|
73
|
+
Then the MenuGroup will render the items using the customMenuItem component
|
|
74
|
+
|
|
75
|
+
Scenario: MenuGroup ignores the menuItemType without items prop
|
|
76
|
+
When a MenuGroup has menuItemType set
|
|
77
|
+
And is not provided with a valid items prop
|
|
78
|
+
Then the menuItemType will be ignored
|
|
79
|
+
|
|
80
|
+
Scenario Outline: MenuGroup can set the type for all MenuItems
|
|
81
|
+
When menuItemType is set to <menu item type>
|
|
82
|
+
And MenuGroup is provided with a valid items prop
|
|
83
|
+
Then each MenuItem rendered by the MenuGroup with use this type
|
|
84
|
+
|
|
85
|
+
Examples: Prop Types
|
|
86
|
+
| menu item type |
|
|
87
|
+
| undefined (default) |
|
|
88
|
+
| checkbox |
|
|
89
|
+
| radio |
|
|
90
|
+
| switch |
|
|
91
|
+
|
|
92
|
+
Scenario Outline: MenuGroup has custom content
|
|
93
|
+
When <custom content> is present at the <location>
|
|
94
|
+
Then <displayed content> will render
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
| custom content | location | displayed content |
|
|
98
|
+
| a red Banner | top | a red Banner at the top of the group |
|
|
99
|
+
| hint text | bottom | hint text at the bottom of the group |
|
|
100
|
+
| text | top and bottom | text at the top and bottom of the group |
|
|
101
|
+
| no content | top | no content at the top of the group |
|
|
102
|
+
| no content | bottom | no content at the bottom of the group |
|
|
103
|
+
|
|
104
|
+
Scenario Outline: MenuRoot checks its types
|
|
105
|
+
When <propName> is passed a value
|
|
106
|
+
Then MenuRoot expects it to be of type <propType>
|
|
107
|
+
|
|
108
|
+
Examples: Prop Types
|
|
109
|
+
| propName | propType |
|
|
110
|
+
| children | ReactNode |
|
|
111
|
+
| title | string |
|
|
112
|
+
| items | TypeItem[] |
|
|
113
|
+
| menuItemType | TypeMenuItemProps['type'] |
|
|
114
|
+
| customMenuItem | tyepof MenuItem |
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { TypeMenuGroupProps } from "./MenuGroupTypes";
|
|
3
|
+
import { StyledMenuGroup, StyledTitle, StyledMenuGroupUl } from "./styles";
|
|
4
|
+
|
|
5
|
+
export const MenuGroup = ({
|
|
6
|
+
titleAs = "h3",
|
|
7
|
+
children,
|
|
8
|
+
title,
|
|
9
|
+
color,
|
|
10
|
+
...rest
|
|
11
|
+
}: TypeMenuGroupProps) => {
|
|
12
|
+
// Generate a unique ID for the title
|
|
13
|
+
const titleId = `menu-group-title-${Math.random().toString(36).slice(2, 11)}`;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<StyledMenuGroup
|
|
17
|
+
role="group"
|
|
18
|
+
aria-labelledby={title ? titleId : undefined}
|
|
19
|
+
// TODO: fix this type since `color` should be valid here. TS can't resolve the correct type.
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
21
|
+
// @ts-ignore
|
|
22
|
+
color={color}
|
|
23
|
+
{...rest}
|
|
24
|
+
>
|
|
25
|
+
{title && (
|
|
26
|
+
<StyledTitle as={titleAs} id={titleId}>
|
|
27
|
+
{title}
|
|
28
|
+
</StyledTitle>
|
|
29
|
+
)}
|
|
30
|
+
<StyledMenuGroupUl>{children}</StyledMenuGroupUl>
|
|
31
|
+
</StyledMenuGroup>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
TypeBorderSystemProps,
|
|
4
|
+
TypeColorSystemProps,
|
|
5
|
+
TypeFlexboxSystemProps,
|
|
6
|
+
TypeGridSystemProps,
|
|
7
|
+
TypeLayoutSystemProps,
|
|
8
|
+
TypePositionSystemProps,
|
|
9
|
+
TypeSpaceSystemProps,
|
|
10
|
+
TypeTypographySystemProps,
|
|
11
|
+
} from "@sproutsocial/seeds-react-system-props";
|
|
12
|
+
|
|
13
|
+
export interface TypeMenuGroupStyledProps
|
|
14
|
+
extends TypeBorderSystemProps,
|
|
15
|
+
TypeColorSystemProps,
|
|
16
|
+
TypeFlexboxSystemProps,
|
|
17
|
+
TypeGridSystemProps,
|
|
18
|
+
TypeLayoutSystemProps,
|
|
19
|
+
TypePositionSystemProps,
|
|
20
|
+
TypeSpaceSystemProps,
|
|
21
|
+
TypeTypographySystemProps {}
|
|
22
|
+
|
|
23
|
+
export interface TypeMenuGroupProps
|
|
24
|
+
extends TypeMenuGroupStyledProps,
|
|
25
|
+
Omit<React.ComponentPropsWithoutRef<"ul">, "color"> {
|
|
26
|
+
title?: string;
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
titleAs?: string | React.ComponentType<any>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
2
|
+
import {
|
|
3
|
+
SingleSelectMenu,
|
|
4
|
+
MenuContent,
|
|
5
|
+
MenuGroup,
|
|
6
|
+
MenuToggleButton,
|
|
7
|
+
MenuItem,
|
|
8
|
+
} from "../../index";
|
|
9
|
+
|
|
10
|
+
const menuItems = [
|
|
11
|
+
{ id: "item-1", label: "Item 1" },
|
|
12
|
+
{ id: "item-2", label: "Item 2" },
|
|
13
|
+
{ id: "item-3", label: "Item 3" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe("MenuGroup", () => {
|
|
17
|
+
xdescribe("User Behavior", () => {
|
|
18
|
+
xit("The first group should be accessible to screen readers", () => {});
|
|
19
|
+
xit("User is able to navigate between groups", () => {});
|
|
20
|
+
});
|
|
21
|
+
describe("API", () => {
|
|
22
|
+
it("renders a group with a title", () => {
|
|
23
|
+
const menuTitle = "menu-title-test";
|
|
24
|
+
render(
|
|
25
|
+
<SingleSelectMenu
|
|
26
|
+
defaultIsOpen
|
|
27
|
+
triggerElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
|
|
28
|
+
>
|
|
29
|
+
<MenuContent>
|
|
30
|
+
<MenuGroup title={menuTitle}>
|
|
31
|
+
{menuItems.map(({ id, label }) => (
|
|
32
|
+
<MenuItem key={id} id={id}>
|
|
33
|
+
{label}
|
|
34
|
+
</MenuItem>
|
|
35
|
+
))}
|
|
36
|
+
</MenuGroup>
|
|
37
|
+
</MenuContent>
|
|
38
|
+
</SingleSelectMenu>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Find associated label for the group. (Menu Group uses aria-labelledby to point to heading element)
|
|
42
|
+
const menuGroup = screen.getByRole("group", { name: menuTitle });
|
|
43
|
+
expect(menuGroup).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("when no title is provided, it should not render a title", () => {
|
|
47
|
+
render(
|
|
48
|
+
<SingleSelectMenu
|
|
49
|
+
defaultIsOpen
|
|
50
|
+
triggerElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
|
|
51
|
+
>
|
|
52
|
+
<MenuContent>
|
|
53
|
+
<MenuGroup>
|
|
54
|
+
{menuItems.map(({ id, label }) => (
|
|
55
|
+
<MenuItem key={id} id={id}>
|
|
56
|
+
{label}
|
|
57
|
+
</MenuItem>
|
|
58
|
+
))}
|
|
59
|
+
</MenuGroup>
|
|
60
|
+
</MenuContent>
|
|
61
|
+
</SingleSelectMenu>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Find a heading element within the MenuGroup component
|
|
65
|
+
const menuTitle = screen.queryByRole("heading");
|
|
66
|
+
expect(menuTitle).not.toBeInTheDocument();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("Renders children correctly", () => {
|
|
70
|
+
render(
|
|
71
|
+
<SingleSelectMenu
|
|
72
|
+
defaultIsOpen
|
|
73
|
+
triggerElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
|
|
74
|
+
>
|
|
75
|
+
<MenuContent>
|
|
76
|
+
<MenuGroup>
|
|
77
|
+
{menuItems.map(({ id, label }) => (
|
|
78
|
+
<MenuItem key={id} id={id}>
|
|
79
|
+
{label}
|
|
80
|
+
</MenuItem>
|
|
81
|
+
))}
|
|
82
|
+
</MenuGroup>
|
|
83
|
+
</MenuContent>
|
|
84
|
+
</SingleSelectMenu>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
screen.getByRole("option", { name: menuItems[0].label })
|
|
89
|
+
).toBeInTheDocument();
|
|
90
|
+
expect(
|
|
91
|
+
screen.getByRole("option", { name: menuItems[1].label })
|
|
92
|
+
).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { MenuGroup, MenuItem } from "../../index";
|
|
2
|
+
import { Text } from "@sproutsocial/seeds-react-text";
|
|
3
|
+
|
|
4
|
+
export const MenuGroupTypeTest = () => (
|
|
5
|
+
<>
|
|
6
|
+
{/* MenuGroup allows children */}
|
|
7
|
+
<MenuGroup>Test</MenuGroup>
|
|
8
|
+
{/* MenuGroup allows children with MenuItems */}
|
|
9
|
+
<MenuGroup>
|
|
10
|
+
<MenuItem id="1">Test 1</MenuItem>
|
|
11
|
+
<MenuItem id="2">Test 2</MenuItem>
|
|
12
|
+
</MenuGroup>
|
|
13
|
+
{/* MenuGroup allows title as a string */}
|
|
14
|
+
<MenuGroup title="title">Test</MenuGroup>
|
|
15
|
+
{/* MenuGroup allows titleAs as a string */}
|
|
16
|
+
<MenuGroup title="title" titleAs="h3">
|
|
17
|
+
Test
|
|
18
|
+
</MenuGroup>
|
|
19
|
+
{/* MenuGroup allows titleAs as a component */}
|
|
20
|
+
<MenuGroup title="title" titleAs={Text}>
|
|
21
|
+
Test
|
|
22
|
+
</MenuGroup>
|
|
23
|
+
{/* MenuGroup accepts ul props */}
|
|
24
|
+
<MenuGroup aria-label="test label">Test</MenuGroup>
|
|
25
|
+
{/* MenuGroup accepts style props */}
|
|
26
|
+
<MenuGroup border="1px solid black" bg="white" width="150px">
|
|
27
|
+
Test
|
|
28
|
+
</MenuGroup>
|
|
29
|
+
|
|
30
|
+
{/* @ts-expect-error - MenuGroup requires children */}
|
|
31
|
+
<MenuGroup />
|
|
32
|
+
{/* @ts-expect-error - MenuGroup title is invalid as number */}
|
|
33
|
+
<MenuGroup title={123}>Test</MenuGroup>
|
|
34
|
+
{/* @ts-expect-error - MenuGroup title is invalid as boolean */}
|
|
35
|
+
<MenuGroup title={false}>Test</MenuGroup>
|
|
36
|
+
{/* @ts-expect-error - MenuGroup titleAs is invalid as number */}
|
|
37
|
+
<MenuGroup title="title" titleAs={123}>
|
|
38
|
+
Test
|
|
39
|
+
</MenuGroup>
|
|
40
|
+
{/* @ts-expect-error - MenuGroup titleAs is invalid as boolean */}
|
|
41
|
+
<MenuGroup title="title" titleAs={false}>
|
|
42
|
+
Test
|
|
43
|
+
</MenuGroup>
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
import Text from "@sproutsocial/seeds-react-text";
|
|
3
|
+
import {
|
|
4
|
+
border,
|
|
5
|
+
layout,
|
|
6
|
+
position,
|
|
7
|
+
flexbox,
|
|
8
|
+
grid,
|
|
9
|
+
color,
|
|
10
|
+
space,
|
|
11
|
+
} from "styled-system";
|
|
12
|
+
import type { TypeMenuGroupProps } from "./MenuGroupTypes";
|
|
13
|
+
|
|
14
|
+
export const StyledMenuGroup = styled.li<TypeMenuGroupProps>`
|
|
15
|
+
&:not(:last-child) {
|
|
16
|
+
border-bottom: 1px solid
|
|
17
|
+
${({ theme }) => theme.colors.container.border.base};
|
|
18
|
+
}
|
|
19
|
+
list-style: none;
|
|
20
|
+
margin: 0;
|
|
21
|
+
${border}
|
|
22
|
+
${position}
|
|
23
|
+
${color}
|
|
24
|
+
${flexbox}
|
|
25
|
+
${grid}
|
|
26
|
+
${layout}
|
|
27
|
+
${space}
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
export const StyledTitle = styled(Text.SmallSubHeadline)`
|
|
31
|
+
margin: ${({ theme }) => theme.space[200]} 0px
|
|
32
|
+
${({ theme }) => theme.space[300]} ${({ theme }) => theme.space[300]};
|
|
33
|
+
`;
|
|
34
|
+
export const StyledMenuGroupUl = styled.ul`
|
|
35
|
+
margin: 0;
|
|
36
|
+
padding: ${({ theme }) => theme.space[300]};
|
|
37
|
+
`;
|