@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
package/jest.config.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const baseConfig = require("@sproutsocial/seeds-testing");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @type {import('jest').Config}
|
|
5
|
+
*/
|
|
6
|
+
const config = {
|
|
7
|
+
...baseConfig,
|
|
8
|
+
displayName: "seeds-react-menu",
|
|
9
|
+
setupFilesAfterEnv: ["@sproutsocial/seeds-testing/setupAfterEnv"],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
module.exports = config;
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sproutsocial/seeds-react-menu",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Seeds React Menu",
|
|
5
|
+
"author": "Sprout Social, Inc.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"module": "dist/esm/index.js",
|
|
9
|
+
"types": "dist/index.d.ts",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup --dts",
|
|
12
|
+
"dev": "tsup --watch --dts",
|
|
13
|
+
"clean": "rm -rf .turbo dist",
|
|
14
|
+
"clean:modules": "rm -rf node_modules",
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"test": "jest",
|
|
17
|
+
"test:watch": "jest --watch --coverage=false"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@sproutsocial/seeds-react-box": "*",
|
|
21
|
+
"@sproutsocial/seeds-react-button": "*",
|
|
22
|
+
"@sproutsocial/seeds-react-icon": "*",
|
|
23
|
+
"@sproutsocial/seeds-react-checkbox": "*",
|
|
24
|
+
"@sproutsocial/seeds-react-popout": "*",
|
|
25
|
+
"@sproutsocial/seeds-react-radio": "*",
|
|
26
|
+
"@sproutsocial/seeds-react-switch": "*",
|
|
27
|
+
"@sproutsocial/seeds-react-system-props": "*",
|
|
28
|
+
"@sproutsocial/seeds-react-theme": "*",
|
|
29
|
+
"downshift": "9.0.4"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@sproutsocial/eslint-config-seeds": "*",
|
|
33
|
+
"@sproutsocial/seeds-react-testing-library": "*",
|
|
34
|
+
"@sproutsocial/seeds-testing": "*",
|
|
35
|
+
"@types/react": "^18.0.0",
|
|
36
|
+
"@types/react-dom": "^18.0.0",
|
|
37
|
+
"@types/styled-components": "^5.1.26",
|
|
38
|
+
"react": "^18.0.0",
|
|
39
|
+
"react-dom": "^18.0.0",
|
|
40
|
+
"typescript": "^5.2.2",
|
|
41
|
+
"tsup": "^8.0.2"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": "^17.0.0 || ^18.0.0",
|
|
45
|
+
"styled-components": "^5.2.3"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Feature: Action Menu
|
|
2
|
+
|
|
3
|
+
A simple Menu for triggering a single action from a list of actions.
|
|
4
|
+
|
|
5
|
+
Rule: API
|
|
6
|
+
Background:
|
|
7
|
+
Given an engineer is implementing a ActionMenu
|
|
8
|
+
|
|
9
|
+
Scenario: ActionMenu extends Menu
|
|
10
|
+
When Menu features are used in ActionMenu
|
|
11
|
+
Then the ActionMenu responds as a Menu would
|
|
12
|
+
|
|
13
|
+
Scenario: ActionMenu accepts a list of MenuItems
|
|
14
|
+
When an array of MenuItems is passed to the ActionMenu as children
|
|
15
|
+
Then the ActionMenu should render the MenuItems
|
|
16
|
+
|
|
17
|
+
Scenario: ActionMenu automatically closes after an item is clicked
|
|
18
|
+
When a MenuItem is clicked
|
|
19
|
+
And the corresponding onClick executes
|
|
20
|
+
Then the ActionMenu should no longer render
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
useSelect,
|
|
4
|
+
type UseSelectProps,
|
|
5
|
+
type UseSelectState,
|
|
6
|
+
type UseSelectSelectedItemChange,
|
|
7
|
+
} from "downshift";
|
|
8
|
+
import type { TypeDefaultItemProps } from "../types";
|
|
9
|
+
import { useItemsFromChildren } from "../hooks/useItemsFromChildren";
|
|
10
|
+
import {
|
|
11
|
+
defaultUseSelectProps,
|
|
12
|
+
useSelectStateReducer,
|
|
13
|
+
} from "../reducers/useSelectStateReducer";
|
|
14
|
+
import {
|
|
15
|
+
MenuRoot,
|
|
16
|
+
type TypeMenuRootProps,
|
|
17
|
+
type TypeMenuRootOwnProps,
|
|
18
|
+
} from "../MenuRoot";
|
|
19
|
+
import { itemToString } from "../utils/itemToString";
|
|
20
|
+
|
|
21
|
+
type TypeInvalidMenuProps =
|
|
22
|
+
| "initialSelectedItem"
|
|
23
|
+
| "defaultSelectedItem"
|
|
24
|
+
| "selectedItem";
|
|
25
|
+
export interface TypeActionMenuProps
|
|
26
|
+
extends TypeMenuRootOwnProps,
|
|
27
|
+
Partial<Omit<UseSelectProps<TypeDefaultItemProps>, TypeInvalidMenuProps>> {
|
|
28
|
+
children: TypeMenuRootProps["children"];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const ActionMenu = ({
|
|
32
|
+
children,
|
|
33
|
+
stateReducer: externalStateReducer,
|
|
34
|
+
...useSelectProps
|
|
35
|
+
}: TypeActionMenuProps) => {
|
|
36
|
+
const { allMenuItems, menuItemsMap } = useItemsFromChildren({ children });
|
|
37
|
+
|
|
38
|
+
// Manually fire the onClick handler when Enter or Space is pressed
|
|
39
|
+
const handleStateChange = (
|
|
40
|
+
changes: UseSelectSelectedItemChange<TypeDefaultItemProps>
|
|
41
|
+
) => {
|
|
42
|
+
const { selectedItem } = changes;
|
|
43
|
+
|
|
44
|
+
// if selectedItem is disabled do nothing
|
|
45
|
+
if (selectedItem.element?.props?.disabled) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Listen for Enter or Space key
|
|
50
|
+
if (
|
|
51
|
+
changes.type === useSelect.stateChangeTypes.ToggleButtonKeyDownEnter ||
|
|
52
|
+
changes.type === useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton
|
|
53
|
+
) {
|
|
54
|
+
// Determine if Enter key was pressed
|
|
55
|
+
const isEnterKeyDown =
|
|
56
|
+
changes.type === useSelect.stateChangeTypes.ToggleButtonKeyDownEnter;
|
|
57
|
+
|
|
58
|
+
// selectedItem props
|
|
59
|
+
const selectedItemProps = selectedItem.element?.props;
|
|
60
|
+
|
|
61
|
+
// Create a synthetic event to pass into the onClick
|
|
62
|
+
const syntheticKeyboardEvent = new KeyboardEvent("keydown", {
|
|
63
|
+
key: isEnterKeyDown ? "Enter" : " ",
|
|
64
|
+
code: isEnterKeyDown ? "Enter" : "Space",
|
|
65
|
+
bubbles: true,
|
|
66
|
+
cancelable: true,
|
|
67
|
+
view: window,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Manually call the onClick handler with a simulated event
|
|
71
|
+
// Cast it to React.MouseEvent to match onClick expectations
|
|
72
|
+
selectedItemProps?.onClick?.(
|
|
73
|
+
syntheticKeyboardEvent as unknown as React.MouseEvent<HTMLElement>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const { isOpen, ...useSelectReturnProps } = useSelect({
|
|
79
|
+
...defaultUseSelectProps,
|
|
80
|
+
items: allMenuItems,
|
|
81
|
+
itemToString,
|
|
82
|
+
onSelectedItemChange: handleStateChange,
|
|
83
|
+
stateReducer: (state, actionAndChanges) => {
|
|
84
|
+
let newState: Partial<UseSelectState<TypeDefaultItemProps>>;
|
|
85
|
+
newState = useSelectStateReducer(state, actionAndChanges);
|
|
86
|
+
return externalStateReducer
|
|
87
|
+
? externalStateReducer(state, {
|
|
88
|
+
...actionAndChanges,
|
|
89
|
+
changes: newState,
|
|
90
|
+
})
|
|
91
|
+
: newState;
|
|
92
|
+
},
|
|
93
|
+
...useSelectProps,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<MenuRoot
|
|
98
|
+
{...{
|
|
99
|
+
isListbox: false,
|
|
100
|
+
items: allMenuItems,
|
|
101
|
+
itemsMap: menuItemsMap,
|
|
102
|
+
itemToString,
|
|
103
|
+
isOpen,
|
|
104
|
+
...useSelectProps,
|
|
105
|
+
...useSelectReturnProps,
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</MenuRoot>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export default ActionMenu;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
} from "@sproutsocial/seeds-react-testing-library";
|
|
8
|
+
import { ActionMenu } from "../ActionMenu";
|
|
9
|
+
import { MenuContent, MenuItem, MenuToggleButton } from "../../index";
|
|
10
|
+
|
|
11
|
+
describe("ActionMenu", () => {
|
|
12
|
+
it.skip("extends Menu", () => {
|
|
13
|
+
// run shared tests for all menus
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should render the MenuItems when provided as children", async () => {
|
|
17
|
+
render(
|
|
18
|
+
<ActionMenu
|
|
19
|
+
triggerElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
|
|
20
|
+
>
|
|
21
|
+
<MenuContent>
|
|
22
|
+
<MenuItem id="test-item-1">Item 1</MenuItem>
|
|
23
|
+
<MenuItem id="test-item-2">Item 2</MenuItem>
|
|
24
|
+
</MenuContent>
|
|
25
|
+
</ActionMenu>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
|
29
|
+
|
|
30
|
+
const toggleBtn = screen.getByRole("button", { name: "Test Menu" });
|
|
31
|
+
fireEvent.click(toggleBtn);
|
|
32
|
+
|
|
33
|
+
await waitFor(() => expect(screen.getByRole("menu")).toBeVisible());
|
|
34
|
+
expect(screen.getByRole("menuitem", { name: "Item 1" })).toBeVisible();
|
|
35
|
+
expect(screen.getByRole("menuitem", { name: "Item 2" })).toBeVisible();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should close after a MenuItem is clicked", async () => {
|
|
39
|
+
const item1MockClick = jest.fn();
|
|
40
|
+
render(
|
|
41
|
+
<ActionMenu
|
|
42
|
+
triggerElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
|
|
43
|
+
>
|
|
44
|
+
<MenuContent>
|
|
45
|
+
<MenuItem id="test-item-1" onClick={item1MockClick}>
|
|
46
|
+
Item 1
|
|
47
|
+
</MenuItem>
|
|
48
|
+
<MenuItem id="test-item-2">Item 2</MenuItem>
|
|
49
|
+
</MenuContent>
|
|
50
|
+
</ActionMenu>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const toggleBtn = screen.getByRole("button", { name: "Test Menu" });
|
|
54
|
+
fireEvent.click(toggleBtn);
|
|
55
|
+
|
|
56
|
+
await waitFor(() => expect(screen.getByRole("menu")).toBeVisible());
|
|
57
|
+
|
|
58
|
+
const item1 = screen.getByRole("menuitem", { name: "Item 1" });
|
|
59
|
+
expect(item1).toBeVisible();
|
|
60
|
+
fireEvent.click(item1);
|
|
61
|
+
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
expect(item1MockClick).toHaveBeenCalled();
|
|
64
|
+
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// TODO: Should be fixed after onClick bug fixed on menu items
|
|
69
|
+
it.skip("should close after a MenuItem is accessed by the keyboard", async () => {
|
|
70
|
+
const item1MockClick = jest.fn();
|
|
71
|
+
const { user } = render(
|
|
72
|
+
<ActionMenu
|
|
73
|
+
triggerElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
|
|
74
|
+
>
|
|
75
|
+
<MenuContent>
|
|
76
|
+
<MenuItem id="test-item-1" onClick={item1MockClick}>
|
|
77
|
+
Item 1
|
|
78
|
+
</MenuItem>
|
|
79
|
+
<MenuItem id="test-item-2">Item 2</MenuItem>
|
|
80
|
+
</MenuContent>
|
|
81
|
+
</ActionMenu>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
|
85
|
+
const toggleBtn = screen.getByRole("button", { name: "Test Menu" });
|
|
86
|
+
fireEvent.click(toggleBtn);
|
|
87
|
+
|
|
88
|
+
await waitFor(() => expect(screen.getByRole("menu")).toBeVisible());
|
|
89
|
+
|
|
90
|
+
await user.keyboard("{arrowdown}{enter}");
|
|
91
|
+
|
|
92
|
+
await waitFor(() => {
|
|
93
|
+
expect(item1MockClick).toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
await waitFor(() => {
|
|
96
|
+
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ActionMenu, MenuContent, MenuHeader, MenuFooter } from "../../index";
|
|
2
|
+
|
|
3
|
+
export const ActionMenuTypeTest = () => (
|
|
4
|
+
<>
|
|
5
|
+
{/* Valid ActionMenu with minimal required props */}
|
|
6
|
+
<ActionMenu triggerElement={<>test</>}>Test</ActionMenu>
|
|
7
|
+
{/* Valid ActionMenu allows MenuContent by itself as a child */}
|
|
8
|
+
<ActionMenu triggerElement={<>test</>}>
|
|
9
|
+
<MenuContent>Test</MenuContent>
|
|
10
|
+
</ActionMenu>
|
|
11
|
+
{/* Valid ActionMenu allows multiple children without fragment */}
|
|
12
|
+
<ActionMenu triggerElement={<>test</>}>
|
|
13
|
+
<MenuHeader>Test</MenuHeader>
|
|
14
|
+
<MenuContent>Test</MenuContent>
|
|
15
|
+
<MenuFooter>Test</MenuFooter>
|
|
16
|
+
</ActionMenu>
|
|
17
|
+
{/* Valid ActionMenu accepts Downshift props and functions for useSelect other than selectedItem */}
|
|
18
|
+
<ActionMenu
|
|
19
|
+
triggerElement={<>test</>}
|
|
20
|
+
onSelectedItemChange={() => {}}
|
|
21
|
+
stateReducer={(c) => c}
|
|
22
|
+
isOpen
|
|
23
|
+
>
|
|
24
|
+
Test
|
|
25
|
+
</ActionMenu>
|
|
26
|
+
{/* Valid ActionMenu accepts Downshift props and functions for useMultipleSelection */}
|
|
27
|
+
<ActionMenu triggerElement={<>test</>}>Test</ActionMenu>
|
|
28
|
+
|
|
29
|
+
{/* @ts-expect-error - triggerElement is a required prop */}
|
|
30
|
+
<ActionMenu>Test</ActionMenu>
|
|
31
|
+
<ActionMenu
|
|
32
|
+
/* @ts-expect-error - triggerElement must be a valid react element */
|
|
33
|
+
triggerElement="test"
|
|
34
|
+
>
|
|
35
|
+
Test
|
|
36
|
+
</ActionMenu>
|
|
37
|
+
{/* @ts-expect-error - when no children provided */}
|
|
38
|
+
<ActionMenu triggerElement="test" />
|
|
39
|
+
|
|
40
|
+
<ActionMenu
|
|
41
|
+
triggerElement={<>test</>}
|
|
42
|
+
/* @ts-expect-error - isOpen can't be a string */
|
|
43
|
+
isOpen="false"
|
|
44
|
+
/* @ts-expect-error - onSelectedItemChange can't be a string */
|
|
45
|
+
onSelectedItemChange="test"
|
|
46
|
+
/* @ts-expect-error - stateReducer must have a return value */
|
|
47
|
+
stateReducer={() => {}}
|
|
48
|
+
>
|
|
49
|
+
Test
|
|
50
|
+
</ActionMenu>
|
|
51
|
+
<ActionMenu
|
|
52
|
+
triggerElement={<>test</>}
|
|
53
|
+
/* @ts-expect-error - selectedItem is never allowed for ActionMenu */
|
|
54
|
+
selectedItem={null}
|
|
55
|
+
>
|
|
56
|
+
Test
|
|
57
|
+
</ActionMenu>
|
|
58
|
+
<ActionMenu
|
|
59
|
+
triggerElement={<>test</>}
|
|
60
|
+
/* @ts-expect-error - initialSelectedItem is never allowed for ActionMenu */
|
|
61
|
+
initialSelectedItem={null}
|
|
62
|
+
>
|
|
63
|
+
Test
|
|
64
|
+
</ActionMenu>
|
|
65
|
+
<ActionMenu
|
|
66
|
+
triggerElement={<>test</>}
|
|
67
|
+
/* @ts-expect-error - defaultSelectedItem is never allowed for ActionMenu */
|
|
68
|
+
defaultSelectedItem={null}
|
|
69
|
+
>
|
|
70
|
+
Test
|
|
71
|
+
</ActionMenu>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./ActionMenu";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Feature: ComboBox
|
|
2
|
+
|
|
3
|
+
A component that allows users to search, select, and manage items with autocomplete functionality.
|
|
4
|
+
|
|
5
|
+
Rule: User behavior
|
|
6
|
+
|
|
7
|
+
Background:
|
|
8
|
+
Given an end user is using the component
|
|
9
|
+
|
|
10
|
+
Scenario: User can autocomplete text inputs
|
|
11
|
+
Given the ComboBox Input is focused
|
|
12
|
+
When text is typed into the input
|
|
13
|
+
Then a list of matching items is displayed
|
|
14
|
+
And an item can be selected from the list
|
|
15
|
+
|
|
16
|
+
Scenario: User can add a new value
|
|
17
|
+
Given the Combobox Input has a value
|
|
18
|
+
When new values are allowed
|
|
19
|
+
And the value has no match
|
|
20
|
+
Then a new value can be created
|
|
21
|
+
|
|
22
|
+
Scenario: User gets a warning on error
|
|
23
|
+
Given the Combobox Input has a value
|
|
24
|
+
When the value is invalid
|
|
25
|
+
Then a warning message appears
|
|
26
|
+
|
|
27
|
+
Scenario: User can select an item from the list
|
|
28
|
+
Given the ComboBox Input has text
|
|
29
|
+
When an item is selected
|
|
30
|
+
Then the item is marked as selected
|
|
31
|
+
And the input reflects the selected item
|
|
32
|
+
|
|
33
|
+
Scenario: Screen reader announces combobox
|
|
34
|
+
Given a screen reader is being used
|
|
35
|
+
When the Combobox input is focused
|
|
36
|
+
Then the screen reader announces it as a "combobox"
|
|
37
|
+
|
|
38
|
+
Scenario: Component handles input change
|
|
39
|
+
When the input value changes
|
|
40
|
+
Then the onChange callback fires
|
|
41
|
+
|
|
42
|
+
Scenario Outline: Custom open/close behavior
|
|
43
|
+
Given the ComboBox is rendered with custom behavior
|
|
44
|
+
When the `<action>` is performed
|
|
45
|
+
Then the `<handler>` is executed
|
|
46
|
+
|
|
47
|
+
Examples: Custom Behavior
|
|
48
|
+
| action | handler |
|
|
49
|
+
| item selected | onSelect |
|
|
50
|
+
| input focused | onFocus |
|
|
51
|
+
| input blurred | onBlur |
|
|
52
|
+
|
|
53
|
+
Scenario Outline: Autocomplete suggestions are displayed
|
|
54
|
+
Given the ComboBox is rendered with items
|
|
55
|
+
When <text> is typed into the input
|
|
56
|
+
Then matching <suggestion> items are displayed
|
|
57
|
+
|
|
58
|
+
Examples: Autocomplete Suggestions
|
|
59
|
+
| text | suggestion |
|
|
60
|
+
| ---------- | ---------------------- |
|
|
61
|
+
| app | apple, application |
|
|
62
|
+
| ban | banana, band |
|
|
63
|
+
|
|
64
|
+
Rule: API
|
|
65
|
+
Background:
|
|
66
|
+
Given an engineer is developing using the component
|
|
67
|
+
|
|
68
|
+
Scenario: ComboBox extends Menu
|
|
69
|
+
When Menu features are used in ComboBox
|
|
70
|
+
Then the ComboBox responds as a Menu would
|
|
71
|
+
|
|
72
|
+
Scenario: ComboBox accepts selected item
|
|
73
|
+
When a selected item is passed to the ComboBox
|
|
74
|
+
Then the input reflects the selected item
|
|
75
|
+
|
|
76
|
+
Scenario: ComboBox hasWarning
|
|
77
|
+
When the input's validation has an error
|
|
78
|
+
Then 'hasWarning' becomes true
|
|
79
|
+
|
|
80
|
+
Scenario Outline: ComboBox checks its types
|
|
81
|
+
When <propName> is passed a value
|
|
82
|
+
Then ComboBox expects it to be of type <propType>
|
|
83
|
+
|
|
84
|
+
Examples: Prop Types
|
|
85
|
+
| propName | propType |
|
|
86
|
+
| delimiters | array of strings |
|
|
87
|
+
| value | string |
|
|
88
|
+
| hasFocus | boolean |
|
|
89
|
+
| onChange | function |
|
|
90
|
+
| onPaste | function |
|
|
91
|
+
| autoFocus | boolean |
|
|
92
|
+
| isInvalid | boolean |
|
|
93
|
+
| hasWarning | boolean |
|
|
94
|
+
| required | boolean |
|
|
95
|
+
| inputProps | any |
|
|
96
|
+
| autocomplete | string |
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Feature: TokenInput
|
|
2
|
+
|
|
3
|
+
A component that allows users to search, select, and manage tokens with autocomplete functionality.
|
|
4
|
+
|
|
5
|
+
Rule: User behavior
|
|
6
|
+
|
|
7
|
+
Background:
|
|
8
|
+
Given an end user is using the component
|
|
9
|
+
|
|
10
|
+
Scenario: User can add tokens
|
|
11
|
+
Given the ComboBox accepts new values
|
|
12
|
+
When text is typed
|
|
13
|
+
And a delimiter key is pressed
|
|
14
|
+
Then a new token is created from the input text
|
|
15
|
+
|
|
16
|
+
Scenario: User can not add tokens
|
|
17
|
+
Given the ComboBox does not accept new values
|
|
18
|
+
When text is typed
|
|
19
|
+
And a delimiter key is pressed
|
|
20
|
+
Then no new token is added
|
|
21
|
+
|
|
22
|
+
Scenario: User can remove tokens manually
|
|
23
|
+
Given the ComboBox has tokens
|
|
24
|
+
When token's remove button is clicked
|
|
25
|
+
Then the token is removed
|
|
26
|
+
|
|
27
|
+
Scenario: User can remove tokens with backspace
|
|
28
|
+
Given the ComboBox has tokens
|
|
29
|
+
When backspace is hit
|
|
30
|
+
Then the token is removed
|
|
31
|
+
|
|
32
|
+
Scenario: User can navigate tokens via keyboard
|
|
33
|
+
Given the ComboBox has tokens
|
|
34
|
+
And the Combobox Input has focus
|
|
35
|
+
When the tokens are navigated using arrow keys
|
|
36
|
+
Then the activeToken callback fires
|
|
37
|
+
|
|
38
|
+
Scenario: Component handles token addition
|
|
39
|
+
When a new token is added
|
|
40
|
+
Then the onAddToken callback fires
|
|
41
|
+
|
|
42
|
+
Scenario: Component handles token removal
|
|
43
|
+
Given the ComboBox is rendered with tokens
|
|
44
|
+
When a token is removed
|
|
45
|
+
Then the onRemoveToken callback fires
|
|
46
|
+
|
|
47
|
+
Scenario: Component handles token click
|
|
48
|
+
When a token is clicked
|
|
49
|
+
Then the onClickToken callback fires
|
|
50
|
+
|
|
51
|
+
Scenario: Component handles input change
|
|
52
|
+
When the input value changes
|
|
53
|
+
Then the onChange callback fires
|
|
54
|
+
|
|
55
|
+
Rule: API
|
|
56
|
+
Background:
|
|
57
|
+
Given an engineer is developing using the component
|
|
58
|
+
|
|
59
|
+
Scenario: TokenInput extends Combobox
|
|
60
|
+
When Combobox features are used in TokenInput
|
|
61
|
+
Then the TokenInput responds as a Combobox would
|
|
62
|
+
|
|
63
|
+
Scenario: ComboBox accepts tokens
|
|
64
|
+
When a token component is passed to the Combobox
|
|
65
|
+
And an item is selected
|
|
66
|
+
Then the token is rendered in the input
|
|
67
|
+
|
|
68
|
+
Scenario: ComboBox has a maximum token length
|
|
69
|
+
When 'tokenMaxLength' is set to <value>
|
|
70
|
+
Then the token string size is limited to <value>
|
|
71
|
+
|
|
72
|
+
Scenario Outline: ComboBox checks its types
|
|
73
|
+
When <propName> is passed a value
|
|
74
|
+
Then ComboBox expects it to be of type <propType>
|
|
75
|
+
|
|
76
|
+
Examples: Prop Types
|
|
77
|
+
| activeToken | string |
|
|
78
|
+
| tokens | array of { id: string, iconName?: EnumIconNames, value: string, valid?: boolean } |
|
|
79
|
+
| onChangeTokens | function |
|
|
80
|
+
| onAddToken | function |
|
|
81
|
+
| onRemoveToken | function |
|
|
82
|
+
| onClickToken | function |
|
|
83
|
+
| maxLength | number |
|
|
84
|
+
| tokenMaxLength | number |
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TypeMenuContentProps } from "./MenuContentTypes";
|
|
2
|
+
import { StyledMenuContent } from "./styles";
|
|
3
|
+
import { useMenuContext } from "../MenuContext";
|
|
4
|
+
|
|
5
|
+
const MenuContent = ({
|
|
6
|
+
id = "menu_content",
|
|
7
|
+
children,
|
|
8
|
+
...rest
|
|
9
|
+
}: TypeMenuContentProps) => {
|
|
10
|
+
const { getToggleButtonProps } = useMenuContext();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<StyledMenuContent
|
|
14
|
+
autoFocus
|
|
15
|
+
id={id}
|
|
16
|
+
tabIndex={0}
|
|
17
|
+
// apply onKeyDown logic for downshift keyboard accessibility support
|
|
18
|
+
onKeyDown={getToggleButtonProps?.().onKeyDown}
|
|
19
|
+
// apply active descendent updates to container for proper accessibility
|
|
20
|
+
aria-activedescendant={getToggleButtonProps?.()["aria-activedescendant"]}
|
|
21
|
+
{...rest}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</StyledMenuContent>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export { MenuContent };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { render, screen } from "@sproutsocial/seeds-react-testing-library";
|
|
2
|
+
import { MenuFooter, MenuHeader, MenuContent } from "../../index";
|
|
3
|
+
import Button from "@sproutsocial/seeds-react-button";
|
|
4
|
+
|
|
5
|
+
describe("MenuContent API", () => {
|
|
6
|
+
it("should render correctly with default props", () => {
|
|
7
|
+
render(
|
|
8
|
+
<MenuContent data-testid="menu-content">
|
|
9
|
+
<div>Test Child</div>
|
|
10
|
+
</MenuContent>
|
|
11
|
+
);
|
|
12
|
+
const menuContent = screen.getByTestId("menu-content");
|
|
13
|
+
expect(menuContent).toBeInTheDocument();
|
|
14
|
+
expect(menuContent).toHaveAttribute("tabIndex", "0");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("MenuContent Behavior", () => {
|
|
19
|
+
it("focus should move properly through menu", async () => {
|
|
20
|
+
const { user } = render(
|
|
21
|
+
<div>
|
|
22
|
+
<MenuHeader>
|
|
23
|
+
<Button data-testid="menu-header-button" onClick={() => {}}>
|
|
24
|
+
Button
|
|
25
|
+
</Button>
|
|
26
|
+
</MenuHeader>
|
|
27
|
+
<MenuContent data-testid="menu-content">
|
|
28
|
+
<div>Test Child</div>
|
|
29
|
+
</MenuContent>
|
|
30
|
+
<MenuFooter>
|
|
31
|
+
<Button data-testid="menu-footer-button" onClick={() => {}}>
|
|
32
|
+
Button 2
|
|
33
|
+
</Button>
|
|
34
|
+
</MenuFooter>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const menuHeaderButton = screen.getByTestId("menu-header-button");
|
|
39
|
+
const menuContent = screen.getByTestId("menu-content");
|
|
40
|
+
const menuFooterButton = screen.getByTestId("menu-footer-button");
|
|
41
|
+
|
|
42
|
+
//Tabs from container into header
|
|
43
|
+
await user.tab();
|
|
44
|
+
expect(menuHeaderButton).toHaveFocus();
|
|
45
|
+
|
|
46
|
+
//Tabs from header onto content
|
|
47
|
+
await user.tab();
|
|
48
|
+
expect(menuContent).toHaveFocus();
|
|
49
|
+
|
|
50
|
+
//Tabs back from content to header
|
|
51
|
+
await user.tab({ shift: true });
|
|
52
|
+
expect(menuHeaderButton).toHaveFocus();
|
|
53
|
+
|
|
54
|
+
//Tabs twice to go from header to footer
|
|
55
|
+
await user.tab();
|
|
56
|
+
await user.tab();
|
|
57
|
+
expect(menuFooterButton).toHaveFocus();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { MenuContent, MenuItem } from "../../index";
|
|
2
|
+
|
|
3
|
+
export const MenuContentTypeTest = () => (
|
|
4
|
+
<>
|
|
5
|
+
{/* MenuContent allows children */}
|
|
6
|
+
<MenuContent>Test</MenuContent>
|
|
7
|
+
{/* MenuContent allows children with MenuItems */}
|
|
8
|
+
<MenuContent>
|
|
9
|
+
<MenuItem id="1">Test 1</MenuItem>
|
|
10
|
+
<MenuItem id="2">Test 2</MenuItem>
|
|
11
|
+
</MenuContent>
|
|
12
|
+
{/* MenuContent accepts ul props */}
|
|
13
|
+
<MenuContent aria-label="test label">Test</MenuContent>
|
|
14
|
+
|
|
15
|
+
{/* @ts-expect-error - MenuContent requires children */}
|
|
16
|
+
<MenuContent />
|
|
17
|
+
</>
|
|
18
|
+
);
|