Skip to content

Commit 42cd61e

Browse files
committed
Create menu component
This component represents a menu that opens on a button press. Feedback on the API is very welcome - I'm pretty happy with how I managed to condense Radix's tree of menu components into a single component, but we need to make sure that it's intuitive for others too.
1 parent 95f85d7 commit 42cd61e

28 files changed

+676
-70
lines changed

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,12 @@
102102
"vitest": "^0.34.4"
103103
},
104104
"dependencies": {
105+
"@radix-ui/react-dropdown-menu": "^2.0.6",
105106
"@radix-ui/react-form": "^0.0.3",
106107
"@radix-ui/react-tooltip": "^1.0.6",
107108
"classnames": "^2.3.2",
108-
"graphemer": "^1.4.0"
109+
"graphemer": "^1.4.0",
110+
"vaul": "^0.7.0"
109111
},
110112
"peerDependencies": {
111113
"@fontsource/inter": "^5",

src/components/Button/Button.module.css

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2023 New Vector Ltd
2+
Copyright 2023 New Vector Ltd
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -72,7 +72,8 @@ limitations under the License.
7272
}
7373
}
7474

75-
.button[data-kind="primary"]:active {
75+
.button[data-kind="primary"]:active,
76+
.button[data-kind="primary"][aria-expanded="true"] {
7677
background: var(--cpd-color-bg-action-primary-pressed);
7778
}
7879

@@ -94,7 +95,8 @@ limitations under the License.
9495
}
9596
}
9697

97-
.button[data-kind="secondary"]:active {
98+
.button[data-kind="secondary"]:active,
99+
.button[data-kind="secondary"][aria-expanded="true"] {
98100
border-color: var(--cpd-color-border-interactive-hovered);
99101
background: var(--cpd-color-bg-subtle-primary);
100102
}
@@ -118,7 +120,8 @@ limitations under the License.
118120
}
119121
}
120122

121-
.button[data-kind="tertiary"]:active {
123+
.button[data-kind="tertiary"]:active,
124+
.button[data-kind="tertiary"][aria-expanded="true"] {
122125
background: var(--cpd-color-bg-subtle-primary);
123126
}
124127

@@ -139,7 +142,8 @@ limitations under the License.
139142
}
140143
}
141144

142-
.button[data-kind="destructive"]:active {
145+
.button[data-kind="destructive"]:active,
146+
.button[data-kind="destructive"][aria-expanded="true"] {
143147
border-color: var(--cpd-color-border-critical-hovered);
144148
background: var(--cpd-color-bg-critical-subtle-hovered);
145149
}

src/components/Menu/DrawerMenu.stories.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg";
2323

2424
import { DrawerMenu as DrawerMenuComponent } from "./DrawerMenu";
2525
import drawerStyles from "./DrawerMenu.module.css";
26-
import { MenuItem } from "../MenuItem/MenuItem";
27-
import { MenuDivider } from "../MenuItem/MenuDivider";
26+
import { MenuItem } from "./MenuItem";
27+
import { MenuDivider } from "./MenuDivider";
2828

2929
export default {
3030
title: "Menu/DrawerMenu",

src/components/Menu/DrawerMenu.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profil
2121
import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg";
2222

2323
import { DrawerMenu } from "./DrawerMenu";
24-
import { MenuItem } from "../MenuItem/MenuItem";
25-
import { MenuDivider } from "../MenuItem/MenuDivider";
24+
import { MenuItem } from "./MenuItem";
25+
import { MenuDivider } from "./MenuDivider";
2626

2727
describe("DrawerMenu", () => {
2828
it("renders", () => {

src/components/Menu/FloatingMenu.stories.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import ChatProblemIcon from "@vector-im/compound-design-tokens/icons/chat-proble
2222
import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg";
2323

2424
import { FloatingMenu as FloatingMenuComponent } from "./FloatingMenu";
25-
import { MenuItem } from "../MenuItem/MenuItem";
26-
import { MenuDivider } from "../MenuItem/MenuDivider";
25+
import { MenuItem } from "./MenuItem";
26+
import { MenuDivider } from "./MenuDivider";
2727

2828
export default {
2929
title: "Menu/FloatingMenu",

src/components/Menu/FloatingMenu.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profil
2121
import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg";
2222

2323
import { FloatingMenu } from "./FloatingMenu";
24-
import { MenuItem } from "../MenuItem/MenuItem";
25-
import { MenuDivider } from "../MenuItem/MenuDivider";
24+
import { MenuItem } from "./MenuItem";
25+
import { MenuDivider } from "./MenuDivider";
2626

2727
describe("FloatingMenu", () => {
2828
it("renders", () => {

src/components/Menu/Menu.stories.tsx

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
Copyright 2023 New Vector Ltd
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { useState } from "react";
18+
import { Meta, StoryFn } from "@storybook/react";
19+
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg";
20+
import NotificationsIcon from "@vector-im/compound-design-tokens/icons/notifications.svg";
21+
import ChatProblemIcon from "@vector-im/compound-design-tokens/icons/chat-problem.svg";
22+
import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg";
23+
24+
import { Menu as MenuComponent } from "./Menu";
25+
import { MenuItem } from "./MenuItem";
26+
import { MenuDivider } from "./MenuDivider";
27+
import { Button } from "../Button/Button";
28+
29+
export default {
30+
title: "Menu",
31+
component: MenuComponent,
32+
tags: ["autodocs"],
33+
argTypes: {},
34+
args: {},
35+
} as Meta<typeof MenuComponent>;
36+
37+
const Template: StoryFn<typeof MenuComponent> = (args) => {
38+
const [open, setOpen] = useState(true);
39+
40+
return (
41+
<MenuComponent
42+
{...args}
43+
title="Settings"
44+
open={open}
45+
onOpenChange={setOpen}
46+
trigger={<Button>Open menu</Button>}
47+
align="start"
48+
>
49+
<MenuItem Icon={UserProfileIcon} label="Profile" onSelect={() => {}} />
50+
<MenuItem
51+
Icon={NotificationsIcon}
52+
label="Notifications"
53+
onSelect={() => {}}
54+
/>
55+
<MenuItem Icon={ChatProblemIcon} label="Feedback" onSelect={() => {}} />
56+
<MenuDivider />
57+
<MenuItem
58+
kind="critical"
59+
Icon={LeaveIcon}
60+
label="Sign out"
61+
onSelect={() => {}}
62+
/>
63+
</MenuComponent>
64+
);
65+
};
66+
67+
export const Menu = Template.bind({});
68+
Menu.args = {};

src/components/Menu/Menu.test.tsx

+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/*
2+
Copyright 2023 New Vector Ltd
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { describe, it, expect, vi } from "vitest";
18+
import { render, screen } from "@testing-library/react";
19+
import React from "react";
20+
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg";
21+
22+
import { Menu } from "./Menu";
23+
import { MenuItem } from "./MenuItem";
24+
import { Button } from "../Button/Button";
25+
import userEvent from "@testing-library/user-event";
26+
import { useTouchscreen } from "../../utils/useTouchscreen";
27+
28+
vi.mock("../../utils/useTouchscreen", () => ({
29+
useTouchscreen: vi.fn(() => false),
30+
}));
31+
32+
async function withTouchscreen(continuation: () => Promise<void>) {
33+
const mock = vi.mocked(useTouchscreen).mockReturnValue(true);
34+
try {
35+
await continuation();
36+
} finally {
37+
mock.mockRestore();
38+
}
39+
}
40+
41+
describe("Menu", () => {
42+
it("opens", async () => {
43+
const onOpenChange = vi.fn();
44+
render(
45+
<Menu
46+
title="Settings"
47+
open={false}
48+
onOpenChange={onOpenChange}
49+
trigger={<Button>Open menu</Button>}
50+
>
51+
<MenuItem Icon={UserProfileIcon} label="Profile" onSelect={() => {}} />
52+
</Menu>,
53+
);
54+
55+
expect(screen.queryByRole("menu")).toBe(null);
56+
await userEvent.click(screen.getByRole("button"));
57+
expect(onOpenChange).toHaveBeenLastCalledWith(true);
58+
});
59+
60+
it("closes as a floating menu", async () => {
61+
const onOpenChange = vi.fn();
62+
render(
63+
<Menu
64+
title="Settings"
65+
open={true}
66+
onOpenChange={onOpenChange}
67+
trigger={<Button>Open menu</Button>}
68+
>
69+
<MenuItem Icon={UserProfileIcon} label="Profile" onSelect={() => {}} />
70+
</Menu>,
71+
);
72+
73+
// Floating menus have a heading
74+
screen.getByRole("menu");
75+
screen.getByRole("heading", { name: "Settings" });
76+
await userEvent.click(screen.getByRole("menuitem", { name: "Profile" }));
77+
expect(onOpenChange).toHaveBeenLastCalledWith(false);
78+
});
79+
80+
it("closes as a drawer menu", async () => {
81+
// Simulate a touchscreen so that the menu turns into a drawer
82+
await withTouchscreen(async () => {
83+
const onOpenChange = vi.fn();
84+
render(
85+
<Menu
86+
title="Settings"
87+
open={true}
88+
onOpenChange={onOpenChange}
89+
trigger={<Button>Open menu</Button>}
90+
>
91+
<MenuItem
92+
Icon={UserProfileIcon}
93+
label="Profile"
94+
onSelect={() => {}}
95+
/>
96+
</Menu>,
97+
);
98+
99+
// Drawers don't have a heading
100+
screen.getByRole("menu");
101+
expect(screen.queryByRole("heading", { name: "Settings" })).toBe(null);
102+
// Intentionally avoiding userEvent here, because that would trigger a
103+
// callback that calls Element.setPointerCapture, which apparently JSDOM
104+
// doesn't implement
105+
screen.getByRole("menuitem", { name: "Profile" }).click();
106+
expect(onOpenChange).toHaveBeenLastCalledWith(false);
107+
});
108+
});
109+
110+
it("doesn't close if preventDefault is called", async () => {
111+
await withTouchscreen(async () => {
112+
const onOpenChange = vi.fn();
113+
render(
114+
<Menu
115+
title="Settings"
116+
open={true}
117+
onOpenChange={onOpenChange}
118+
trigger={<Button>Open menu</Button>}
119+
>
120+
<MenuItem
121+
Icon={UserProfileIcon}
122+
label="Profile"
123+
onSelect={(e) => e.preventDefault()}
124+
/>
125+
</Menu>,
126+
);
127+
128+
screen.getByRole("menu");
129+
expect(screen.queryByRole("heading", { name: "Settings" })).toBe(null);
130+
screen.getByRole("menuitem", { name: "Profile" }).click();
131+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
132+
});
133+
});
134+
});

0 commit comments

Comments
 (0)