@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.
Files changed (91) hide show
  1. package/.eslintrc.js +4 -0
  2. package/.turbo/turbo-build.log +21 -0
  3. package/CHANGELOG.md +46 -0
  4. package/dist/esm/index.js +2651 -0
  5. package/dist/esm/index.js.map +1 -0
  6. package/dist/index.d.mts +197 -0
  7. package/dist/index.d.ts +197 -0
  8. package/dist/index.js +2670 -0
  9. package/dist/index.js.map +1 -0
  10. package/jest.config.js +12 -0
  11. package/package.json +50 -0
  12. package/src/ActionMenu/ActionMenu.feature +20 -0
  13. package/src/ActionMenu/ActionMenu.tsx +113 -0
  14. package/src/ActionMenu/__tests__/ActionMenu.test.tsx +99 -0
  15. package/src/ActionMenu/__tests__/ActionMenu.typetest.tsx +73 -0
  16. package/src/ActionMenu/index.ts +1 -0
  17. package/src/ComboBox/ComboBox.feature +96 -0
  18. package/src/ComboBox/TokenInput.feature +84 -0
  19. package/src/MenuContent/MenuContent.tsx +28 -0
  20. package/src/MenuContent/MenuContentTypes.ts +7 -0
  21. package/src/MenuContent/__tests__/MenuContent.test.tsx +59 -0
  22. package/src/MenuContent/__tests__/MenuContent.typetest.tsx +18 -0
  23. package/src/MenuContent/index.tsx +2 -0
  24. package/src/MenuContent/styles.tsx +10 -0
  25. package/src/MenuContext.tsx +116 -0
  26. package/src/MenuFooter/MenuFooter.feature +30 -0
  27. package/src/MenuFooter/MenuFooter.tsx +7 -0
  28. package/src/MenuFooter/MenuFooterTypes.ts +1 -0
  29. package/src/MenuFooter/__tests__/MenuFooter.test.tsx +76 -0
  30. package/src/MenuFooter/__tests__/MenuFooter.typetest.tsx +20 -0
  31. package/src/MenuFooter/index.ts +2 -0
  32. package/src/MenuFooter/styles.tsx +7 -0
  33. package/src/MenuGroup/MenuGroup.feature +114 -0
  34. package/src/MenuGroup/MenuGroup.tsx +33 -0
  35. package/src/MenuGroup/MenuGroupTypes.ts +29 -0
  36. package/src/MenuGroup/__tests__/MenuGroup.test.tsx +95 -0
  37. package/src/MenuGroup/__tests__/MenuGroup.typetest.tsx +45 -0
  38. package/src/MenuGroup/index.tsx +8 -0
  39. package/src/MenuGroup/styles.tsx +37 -0
  40. package/src/MenuHeader/MenuHeader.feature +59 -0
  41. package/src/MenuHeader/MenuHeader.tsx +30 -0
  42. package/src/MenuHeader/MenuHeaderTypes.ts +26 -0
  43. package/src/MenuHeader/__tests__/MenuHeader.test.tsx +67 -0
  44. package/src/MenuHeader/__tests__/MenuHeader.typetest.tsx +27 -0
  45. package/src/MenuHeader/index.ts +2 -0
  46. package/src/MenuHeader/styles.tsx +75 -0
  47. package/src/MenuItem/MenuItem.feature +88 -0
  48. package/src/MenuItem/MenuItem.tsx +44 -0
  49. package/src/MenuItem/MenuItemTypes.ts +37 -0
  50. package/src/MenuItem/__tests__/MenuItem.test.tsx +228 -0
  51. package/src/MenuItem/__tests__/MenuItem.typetest.tsx +41 -0
  52. package/src/MenuItem/index.ts +5 -0
  53. package/src/MenuItem/styles.tsx +71 -0
  54. package/src/MenuItemSelectable/MenuItemSelectable.feature +99 -0
  55. package/src/MenuItemSelectable/MenuItemSelectable.tsx +136 -0
  56. package/src/MenuItemSelectable/MenuItemSelectableTypes.ts +15 -0
  57. package/src/MenuItemSelectable/__tests__/MenuItemSelectable.test.tsx +303 -0
  58. package/src/MenuItemSelectable/__tests__/MenuItemSelectable.typetest.tsx +56 -0
  59. package/src/MenuItemSelectable/index.ts +5 -0
  60. package/src/MenuItemSelectable/styles.tsx +21 -0
  61. package/src/MenuLabel.tsx +12 -0
  62. package/src/MenuRoot/MenuRoot.feature +93 -0
  63. package/src/MenuRoot/MenuRoot.tsx +70 -0
  64. package/src/MenuRoot/__tests__/MenuRoot.test.tsx +197 -0
  65. package/src/MenuRoot/__tests__/MenuRoot.typetest.tsx +76 -0
  66. package/src/MenuRoot/index.ts +1 -0
  67. package/src/MenuToggleButton.tsx +29 -0
  68. package/src/MultiSelectMenu/MultiSelectMenu.feature +70 -0
  69. package/src/MultiSelectMenu/MultiSelectMenu.tsx +133 -0
  70. package/src/MultiSelectMenu/MultiSelectMenuTypes.ts +8 -0
  71. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.test.tsx +349 -0
  72. package/src/MultiSelectMenu/__tests__/MultiSelectMenu.typetest.tsx +78 -0
  73. package/src/MultiSelectMenu/index.ts +1 -0
  74. package/src/SingleSelectMenu/SingleSelectMenu.feature +23 -0
  75. package/src/SingleSelectMenu/SingleSelectMenu.tsx +56 -0
  76. package/src/SingleSelectMenu/SingleSelectMenuTypes.ts +9 -0
  77. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.test.tsx +167 -0
  78. package/src/SingleSelectMenu/__tests__/SingleSelectMenu.typetest.tsx +95 -0
  79. package/src/SingleSelectMenu/index.ts +3 -0
  80. package/src/__tests__/Menu.test.tsx +423 -0
  81. package/src/constants.ts +17 -0
  82. package/src/hooks/useItemsFromChildren.tsx +81 -0
  83. package/src/index.ts +20 -0
  84. package/src/menuTestingUtils.tsx +72 -0
  85. package/src/reducers/__tests__/useSelectStateReducer.test.tsx +37 -0
  86. package/src/reducers/useSelectStateReducer.tsx +27 -0
  87. package/src/styled.d.ts +7 -0
  88. package/src/types.ts +16 -0
  89. package/src/utils/itemToString.ts +5 -0
  90. package/tsconfig.json +12 -0
  91. 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,5 @@
1
+ export * from "./MenuItemSelectable";
2
+
3
+ export * from "./MenuItemSelectableTypes";
4
+
5
+ export * from "./styles";
@@ -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 |