diff --git a/package.json b/package.json index e8c62900..88127e54 100644 --- a/package.json +++ b/package.json @@ -105,11 +105,13 @@ "vitest": "^0.34.4" }, "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-form": "^0.0.3", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.6", "classnames": "^2.3.2", - "graphemer": "^1.4.0" + "graphemer": "^1.4.0", + "vaul": "^0.7.0" }, "peerDependencies": { "@fontsource/inconsolata": "^5", diff --git a/src/components/Button/Button.module.css b/src/components/Button/Button.module.css index e24a6a03..ffb0319e 100644 --- a/src/components/Button/Button.module.css +++ b/src/components/Button/Button.module.css @@ -1,5 +1,5 @@ /* -Copyright 2023 New Vector Ltd +Copyright 2023 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -71,7 +71,8 @@ limitations under the License. } } -.button[data-kind="primary"]:active { +.button[data-kind="primary"]:active, +.button[data-kind="primary"][aria-expanded="true"] { background: var(--cpd-color-bg-action-primary-pressed); } @@ -93,7 +94,8 @@ limitations under the License. } } -.button[data-kind="secondary"]:active { +.button[data-kind="secondary"]:active, +.button[data-kind="secondary"][aria-expanded="true"] { border-color: var(--cpd-color-border-interactive-hovered); background: var(--cpd-color-bg-subtle-primary); } @@ -117,7 +119,8 @@ limitations under the License. } } -.button[data-kind="tertiary"]:active { +.button[data-kind="tertiary"]:active, +.button[data-kind="tertiary"][aria-expanded="true"] { background: var(--cpd-color-bg-subtle-primary); } @@ -138,7 +141,8 @@ limitations under the License. } } -.button[data-kind="destructive"]:active { +.button[data-kind="destructive"]:active, +.button[data-kind="destructive"][aria-expanded="true"] { border-color: var(--cpd-color-border-critical-hovered); background: var(--cpd-color-bg-critical-subtle-hovered); } diff --git a/src/components/Menu/DrawerMenu.module.css b/src/components/Menu/DrawerMenu.module.css index 37ed892f..72e17a8e 100644 --- a/src/components/Menu/DrawerMenu.module.css +++ b/src/components/Menu/DrawerMenu.module.css @@ -73,6 +73,9 @@ limitations under the License. contain: paint; overflow: auto; scrollbar-width: none; + + --cpd-separator-spacing: 0; + --cpd-separator-inset: var(--cpd-space-4x); } .body::before { diff --git a/src/components/Menu/DrawerMenu.stories.tsx b/src/components/Menu/DrawerMenu.stories.tsx index 0e59e2ef..490f035a 100644 --- a/src/components/Menu/DrawerMenu.stories.tsx +++ b/src/components/Menu/DrawerMenu.stories.tsx @@ -23,8 +23,8 @@ import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg"; import { DrawerMenu as DrawerMenuComponent } from "./DrawerMenu"; import drawerStyles from "./DrawerMenu.module.css"; -import { MenuItem } from "../MenuItem/MenuItem"; -import { MenuDivider } from "../MenuItem/MenuDivider"; +import { MenuItem } from "./MenuItem"; +import { Separator } from "../Separator/Separator"; export default { title: "Menu/DrawerMenu", @@ -44,7 +44,7 @@ const Template: StoryFn = (args) => ( onSelect={() => {}} /> {}} /> - + { it("renders", () => { const { asFragment } = render( {}} /> - + { @@ -41,7 +41,7 @@ export const DrawerMenu = forwardRef( ref={ref} className={classNames(className, styles.drawer)} aria-label={title} - data-platform={platform} + data-platform={getPlatform()} {...props} role="menu" > diff --git a/src/components/Menu/FloatingMenu.module.css b/src/components/Menu/FloatingMenu.module.css index b584b7ff..bbfa0e8f 100644 --- a/src/components/Menu/FloatingMenu.module.css +++ b/src/components/Menu/FloatingMenu.module.css @@ -31,6 +31,9 @@ limitations under the License. flex-direction: column; gap: var(--cpd-space-1x); padding-block: var(--cpd-space-5x) var(--cpd-space-4x); + + --cpd-separator-spacing: 0; + --cpd-separator-inset: var(--cpd-space-4x); } @keyframes slide-in { diff --git a/src/components/Menu/FloatingMenu.stories.tsx b/src/components/Menu/FloatingMenu.stories.tsx index 6efd4501..47ee60ee 100644 --- a/src/components/Menu/FloatingMenu.stories.tsx +++ b/src/components/Menu/FloatingMenu.stories.tsx @@ -22,8 +22,8 @@ import ChatProblemIcon from "@vector-im/compound-design-tokens/icons/chat-proble import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg"; import { FloatingMenu as FloatingMenuComponent } from "./FloatingMenu"; -import { MenuItem } from "../MenuItem/MenuItem"; -import { MenuDivider } from "../MenuItem/MenuDivider"; +import { MenuItem } from "./MenuItem"; +import { Separator } from "../Separator/Separator"; export default { title: "Menu/FloatingMenu", @@ -42,7 +42,7 @@ const Template: StoryFn = (args) => ( onSelect={() => {}} /> {}} /> - + { it("renders", () => { const { asFragment } = render( {}} /> - + ; + +const Template: StoryFn = (args) => { + const [open, setOpen] = useState(true); + + return ( + Open menu} + align="start" + > + {}} /> + {}} + /> + {}} /> + + {}} + /> + + ); +}; + +export const Menu = Template.bind({}); +Menu.args = {}; diff --git a/src/components/Menu/Menu.test.tsx b/src/components/Menu/Menu.test.tsx new file mode 100644 index 00000000..4b23f73e --- /dev/null +++ b/src/components/Menu/Menu.test.tsx @@ -0,0 +1,135 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg"; + +import { Menu } from "./Menu"; +import { MenuItem } from "./MenuItem"; +import { Button } from "../Button/Button"; +import userEvent from "@testing-library/user-event"; +import { getPlatform } from "../../utils/platform"; + +vi.mock("../../utils/platform", () => ({ getPlatform: vi.fn(() => "other") })); + +async function withPlatform( + platform: ReturnType, + continuation: () => Promise, +) { + const mock = vi.mocked(getPlatform).mockReturnValue(platform); + try { + await continuation(); + } finally { + mock.mockRestore(); + } +} + +describe("Menu", () => { + it("opens", async () => { + const onOpenChange = vi.fn(); + render( + Open menu} + > + {}} /> + , + ); + + expect(screen.queryByRole("menu")).toBe(null); + await userEvent.click(screen.getByRole("button")); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + }); + + it("closes as a floating menu", async () => { + const onOpenChange = vi.fn(); + render( + Open menu} + > + {}} /> + , + ); + + // Floating menus have a heading + screen.getByRole("menu"); + screen.getByRole("heading", { name: "Settings" }); + await userEvent.click(screen.getByRole("menuitem", { name: "Profile" })); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + + it("closes as a drawer menu", async () => { + // Simulate a touchscreen so that the menu turns into a drawer + await withPlatform("android", async () => { + const onOpenChange = vi.fn(); + render( + Open menu} + > + {}} + /> + , + ); + + // Drawers don't have a heading + screen.getByRole("menu"); + expect(screen.queryByRole("heading", { name: "Settings" })).toBe(null); + // Intentionally avoiding userEvent here, because that would trigger a + // callback that calls Element.setPointerCapture, which apparently JSDOM + // doesn't implement + screen.getByRole("menuitem", { name: "Profile" }).click(); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + }); + }); + + it("doesn't close if preventDefault is called", async () => { + await withPlatform("ios", async () => { + const onOpenChange = vi.fn(); + render( + Open menu} + > + e.preventDefault()} + /> + , + ); + + screen.getByRole("menu"); + expect(screen.queryByRole("heading", { name: "Settings" })).toBe(null); + screen.getByRole("menuitem", { name: "Profile" }).click(); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx new file mode 100644 index 00000000..7495d5d5 --- /dev/null +++ b/src/components/Menu/Menu.tsx @@ -0,0 +1,128 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { FC, ReactNode, useMemo } from "react"; +import { + Root, + Trigger, + Portal, + Content, + DropdownMenuItem, +} from "@radix-ui/react-dropdown-menu"; +import { FloatingMenu } from "./FloatingMenu"; +import { Drawer } from "vaul"; +import classnames from "classnames"; +import drawerMenu from "./DrawerMenu.module.css"; +import { MenuContext, MenuData, MenuItemWrapperProps } from "./MenuContext"; +import { DrawerMenu } from "./DrawerMenu"; +import { getPlatform } from "../../utils/platform"; + +interface Props { + /** + * The menu title. + */ + title: string; + /** + * Whether the menu is open. + */ + open: boolean; + /** + * Event handler called when the open state of the menu changes. This includes + * anything like clicking the trigger, selecting a menu item, or dismissing + * the menu with the mouse or keyboard. + */ + onOpenChange: (open: boolean) => void; + /** + * The button that opens the menu. This must be a component that accepts a ref + * and spreads props. + * https://www.radix-ui.com/primitives/docs/guides/composition + */ + trigger: ReactNode; + /** + * The menu contents. + */ + children: ReactNode; + /** + * The side of the trigger on which to place the menu. Note that the menu may + * still end up on a different side than the one you request if there isn't + * enough space. + * @default bottom + */ + side?: "top" | "right" | "bottom" | "left"; + /** + * The edge along which the menu and trigger will be aligned. + * @default center + */ + align?: "start" | "center" | "end"; +} + +const DropdownMenuItemWrapper: FC = ({ + onSelect, + children, +}) => ( + + {children} + +); + +/** + * A menu opened by pressing a button. + */ +export const Menu: FC = ({ + title, + open, + onOpenChange, + trigger, + children: childrenProp, + side = "bottom", + align = "center", +}) => { + // Normally, the menu takes the form of a floating box. But on Android and + // iOS, the menu should morph into a drawer + const platform = getPlatform(); + const drawer = platform === "android" || platform === "ios"; + const context: MenuData = useMemo( + () => ({ + MenuItemWrapper: drawer ? null : DropdownMenuItemWrapper, + onOpenChange, + }), + [onOpenChange], + ); + const children = ( + {childrenProp} + ); + + return drawer ? ( + + {trigger} + + + + {children} + + + + ) : ( + + {trigger} + + + {children} + + + + ); +}; diff --git a/src/components/Menu/MenuContext.tsx b/src/components/Menu/MenuContext.tsx new file mode 100644 index 00000000..315e3de3 --- /dev/null +++ b/src/components/Menu/MenuContext.tsx @@ -0,0 +1,44 @@ +/* +Copyright 2023 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ComponentType, ReactNode, createContext } from "react"; + +export interface MenuItemWrapperProps { + /** + * Event callback for when the item is selected via mouse, touch, or keyboard. + * Calling event.preventDefault in this handler will prevent the menu from + * being dismissed. + */ + onSelect: (e: Event) => void; + children: ReactNode; +} + +export interface MenuData { + /** + * A component that wraps interactive menu items. + */ + MenuItemWrapper: ComponentType | null; + /** + * Event handler called when the open state of the menu changes. + */ + onOpenChange: (open: boolean) => void; +} + +/** + * A React context providing information about the menu in which a given + * component resides. + */ +export const MenuContext = createContext(null); diff --git a/src/components/MenuItem/MenuItem.module.css b/src/components/Menu/MenuItem.module.css similarity index 94% rename from src/components/MenuItem/MenuItem.module.css rename to src/components/Menu/MenuItem.module.css index 017a461c..026034f1 100644 --- a/src/components/MenuItem/MenuItem.module.css +++ b/src/components/Menu/MenuItem.module.css @@ -41,11 +41,6 @@ limitations under the License. grid-template: "icon ." auto / auto 1fr; } -/* Items with interactive controls like toggles are larger by default */ -label.item { - min-inline-size: 246px; -} - .icon { grid-area: icon; margin-inline-end: var(--cpd-space-3x); @@ -119,7 +114,7 @@ button.item { } .item.interactive[data-kind="critical"]:active { - background: var(--cpd-color-bg-critical-subtle); + background: var(--cpd-color-bg-critical-subtle-hovered); } .item[data-kind].disabled { diff --git a/src/components/MenuItem/MenuItem.stories.tsx b/src/components/Menu/MenuItem.stories.tsx similarity index 93% rename from src/components/MenuItem/MenuItem.stories.tsx rename to src/components/Menu/MenuItem.stories.tsx index f2c68125..d700f07a 100644 --- a/src/components/MenuItem/MenuItem.stories.tsx +++ b/src/components/Menu/MenuItem.stories.tsx @@ -24,7 +24,7 @@ import { MenuItem as MenuItemComponent } from "./MenuItem"; import { Text } from "../Typography/Text"; export default { - title: "MenuItem", + title: "Menu/MenuItem", component: MenuItemComponent, tags: ["autodocs"], argTypes: { @@ -51,7 +51,7 @@ export default { export const Example = { render: () => (
- + {}}> 99 @@ -59,8 +59,9 @@ export const Example = { {}} /> - + {}}> Third item without a label diff --git a/src/components/MenuItem/MenuItem.test.tsx b/src/components/Menu/MenuItem.test.tsx similarity index 90% rename from src/components/MenuItem/MenuItem.test.tsx rename to src/components/Menu/MenuItem.test.tsx index c80c8095..1033d5cf 100644 --- a/src/components/MenuItem/MenuItem.test.tsx +++ b/src/components/Menu/MenuItem.test.tsx @@ -39,14 +39,19 @@ const { describe("MenuItem", () => { it("renders", () => { const { asFragment } = render( - , + {}} + />, ); expect(asFragment()).toMatchSnapshot(); }); it("renders with a child", () => { const { asFragment } = render( - + {}}> 10 @@ -57,7 +62,7 @@ describe("MenuItem", () => { it("renders without a label", () => { const { asFragment } = render( - + {}}> Imagine that there might be a volume slider here in place of the label , ); diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/Menu/MenuItem.tsx similarity index 62% rename from src/components/MenuItem/MenuItem.tsx rename to src/components/Menu/MenuItem.tsx index 488baf58..50f42850 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/Menu/MenuItem.tsx @@ -19,11 +19,15 @@ import React, { ComponentPropsWithoutRef, ComponentType, ElementType, + ReactNode, SVGAttributes, + useCallback, + useContext, } from "react"; import styles from "./MenuItem.module.css"; import { Text } from "../Typography/Text"; import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg"; +import { MenuContext } from "./MenuContext"; type MenuItemElement = "button" | "label" | "a" | "div"; @@ -45,15 +49,21 @@ type Props = { * The label to show on this menu item. */ // This prop is required because it's rare to not want a label - label: string | undefined; + label: string | null; + /** + * Event callback for when the item is selected via mouse, touch, or keyboard. + * Calling event.preventDefault in this handler will prevent the menu from + * being dismissed. + */ + // This prop is required because it's rare to not want a selection handler + onSelect: ((e: Event) => void) | null; /** * The color variant of the menu item. * @default primary */ kind?: "primary" | "critical"; - disabled?: boolean; -} & ComponentPropsWithoutRef; +} & Omit, "onSelect">; /** * An item within a menu, acting either as a navigation button, or simply a @@ -64,27 +74,48 @@ export const MenuItem = ({ className, Icon, label, + onSelect, kind = "primary", children, + onClick: onClickProp, disabled, ...props -}: Props): JSX.Element => { +}: Props): ReactNode => { const Component = as ?? ("button" as ElementType); + const context = useContext(MenuContext); - return ( + const onClick = useCallback( + (e: Parameters>[0]) => { + (onClickProp as ((e_: typeof e) => void) | undefined)?.(e); + // If there is no wrapper component to automatically handle onSelect, we + // need to handle it manually, dismissing the menu as the default action + if (onSelect !== null && context?.MenuItemWrapper == null) { + const selectEvent = new CustomEvent("menu.itemSelect", { + bubbles: true, + cancelable: true, + }); + onSelect(selectEvent); + if (!selectEvent.defaultPrevented) context?.onOpenChange(false); + } + }, + [context, onSelect], + ); + + const content = ( - {label !== undefined && ( + {label !== null && ( {label} @@ -102,4 +133,12 @@ export const MenuItem = ({ {children} ); + + return context?.MenuItemWrapper == null || onSelect === null ? ( + content + ) : ( + + {content} + + ); }; diff --git a/src/components/MenuItem/ToggleMenuItem.stories.tsx b/src/components/Menu/ToggleMenuItem.stories.tsx similarity index 97% rename from src/components/MenuItem/ToggleMenuItem.stories.tsx rename to src/components/Menu/ToggleMenuItem.stories.tsx index a494a2b5..b671e35b 100644 --- a/src/components/MenuItem/ToggleMenuItem.stories.tsx +++ b/src/components/Menu/ToggleMenuItem.stories.tsx @@ -22,7 +22,7 @@ import ChatIcon from "@vector-im/compound-design-tokens/icons/chat.svg"; import { ToggleMenuItem as ToggleMenuItemComponent } from "./ToggleMenuItem"; export default { - title: "ToggleMenuItem", + title: "Menu/ToggleMenuItem", component: ToggleMenuItemComponent, tags: ["autodocs"], argTypes: {}, diff --git a/src/components/MenuItem/ToggleMenuItem.test.tsx b/src/components/Menu/ToggleMenuItem.test.tsx similarity index 89% rename from src/components/MenuItem/ToggleMenuItem.test.tsx rename to src/components/Menu/ToggleMenuItem.test.tsx index 6c035fe6..2c25daea 100644 --- a/src/components/MenuItem/ToggleMenuItem.test.tsx +++ b/src/components/Menu/ToggleMenuItem.test.tsx @@ -24,7 +24,11 @@ import { ToggleMenuItem } from "./ToggleMenuItem"; describe("ToggleMenuItem", () => { it("renders", () => { const { asFragment } = render( - , + {}} + />, ); expect(asFragment()).toMatchSnapshot(); }); diff --git a/src/components/MenuItem/ToggleMenuItem.tsx b/src/components/Menu/ToggleMenuItem.tsx similarity index 88% rename from src/components/MenuItem/ToggleMenuItem.tsx rename to src/components/Menu/ToggleMenuItem.tsx index a66df728..71789366 100644 --- a/src/components/MenuItem/ToggleMenuItem.tsx +++ b/src/components/Menu/ToggleMenuItem.tsx @@ -21,7 +21,7 @@ import useId from "../../utils/useId"; type Props = Pick< ComponentProps, - "className" | "Icon" | "label" + "className" | "Icon" | "label" | "onSelect" > & Omit, "id" | "children">; @@ -30,7 +30,10 @@ type Props = Pick< * activate the toggle. */ export const ToggleMenuItem = forwardRef( - function ToggleMenuItem({ className, Icon, label, ...toggleProps }, ref) { + function ToggleMenuItem( + { className, Icon, label, onSelect, ...toggleProps }, + ref, + ) { const toggleId = useId(); return ( ( className={className} Icon={Icon} label={label} + onSelect={onSelect} > diff --git a/src/components/Menu/__snapshots__/DrawerMenu.test.tsx.snap b/src/components/Menu/__snapshots__/DrawerMenu.test.tsx.snap index e66c06c5..49cdd017 100644 --- a/src/components/Menu/__snapshots__/DrawerMenu.test.tsx.snap +++ b/src/components/Menu/__snapshots__/DrawerMenu.test.tsx.snap @@ -12,13 +12,13 @@ exports[`DrawerMenu > renders 1`] = ` class="_body_31bf0e" > - -