diff --git a/package.json b/package.json index 83c3c23b..04963fdf 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "vitest": "^0.34.4" }, "dependencies": { + "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-form": "^0.0.3", "@radix-ui/react-separator": "^1.0.3", diff --git a/src/components/Menu/ContextMenu.stories.tsx b/src/components/Menu/ContextMenu.stories.tsx new file mode 100644 index 00000000..b51a20d2 --- /dev/null +++ b/src/components/Menu/ContextMenu.stories.tsx @@ -0,0 +1,80 @@ +/* +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 from "react"; +import { Meta, StoryFn } from "@storybook/react"; +import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg"; +import NotificationsIcon from "@vector-im/compound-design-tokens/icons/notifications.svg"; +import ChatProblemIcon from "@vector-im/compound-design-tokens/icons/chat-problem.svg"; +import LeaveIcon from "@vector-im/compound-design-tokens/icons/leave.svg"; + +import { ContextMenu as ContextMenuComponent } from "./ContextMenu"; +import { MenuItem } from "./MenuItem"; +import { Separator } from "../Separator/Separator"; + +export default { + title: "Menu/ContextMenu", + component: ContextMenuComponent, + tags: ["autodocs"], + argTypes: {}, + args: {}, +} as Meta; + +const Template: StoryFn = (args) => { + return ( + + Right click or long press to open menu + + } + hasAccessibleAlternative={false} + > + {}} /> + {}} + /> + {}} /> + + {}} + /> + + ); +}; + +export const ContextMenu = Template.bind({}); +ContextMenu.args = {}; diff --git a/src/components/Menu/ContextMenu.test.tsx b/src/components/Menu/ContextMenu.test.tsx new file mode 100644 index 00000000..aa75614f --- /dev/null +++ b/src/components/Menu/ContextMenu.test.tsx @@ -0,0 +1,50 @@ +/* +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 { ContextMenu } from "./ContextMenu"; +import { MenuItem } from "./MenuItem"; +import userEvent from "@testing-library/user-event"; + +describe("ContextMenu", () => { + it("opens by right-clicking", async () => { + const onOpenChange = vi.fn(); + render( + Open menu} + hasAccessibleAlternative + > + {}} /> + , + ); + + expect(screen.queryByRole("menu")).toBe(null); + // Right-click the trigger area + const trigger = screen.getByText("Open menu"); + await userEvent.pointer([{ target: trigger }, { keys: "[MouseRight]" }]); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + }); + + // Here it would be nice to also test opening by long-pressing, but that + // requires fake timers, and user-event appears to stall out when using fake + // timers :( +}); diff --git a/src/components/Menu/ContextMenu.tsx b/src/components/Menu/ContextMenu.tsx new file mode 100644 index 00000000..bad853fe --- /dev/null +++ b/src/components/Menu/ContextMenu.tsx @@ -0,0 +1,142 @@ +/* +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, useCallback, useMemo, useState } from "react"; +import { + Root, + Trigger, + Portal, + Content, + ContextMenuItem, +} from "@radix-ui/react-context-menu"; +import { FloatingMenu } from "./FloatingMenu"; +import { Drawer } from "vaul"; +import classnames from "classnames"; +import drawerStyles 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; + /** + * Event handler called when the open state of the menu changes. + */ + onOpenChange?: (open: boolean) => void; + /** + * The trigger that can be right-clicked or long-pressed to open 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; + /** + * Whether the functionality of this menu is available through some other + * keyboard-accessible means. Preferably this should be true, because context + * menus are potentially difficult to discover, but if false the trigger will + * become focusable so that it can be opened via keyboard navigation. + */ + hasAccessibleAlternative: boolean; + /** + * The menu contents. + */ + children: ReactNode; +} + +const ContextMenuItemWrapper: FC = ({ + onSelect, + children, +}) => ( + + {children} + +); + +/** + * A menu opened by right-clicking or long-pressing another UI element. + */ +export const ContextMenu: FC = ({ + title, + onOpenChange: onOpenChangeProp, + trigger: triggerProp, + hasAccessibleAlternative, + children: childrenProp, +}) => { + const [open, setOpen] = useState(false); + const onOpenChange = useCallback( + (value: boolean) => { + setOpen(value); + onOpenChangeProp?.(value); + }, + [setOpen, onOpenChangeProp], + ); + + // 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 : ContextMenuItemWrapper, + onOpenChange, + }), + [onOpenChange], + ); + const children = ( + {childrenProp} + ); + + const trigger = ( + + {triggerProp} + + ); + + // This is a small hack: Vaul drawers only support buttons as triggers, so + // we end up mounting an empty Radix context menu tree alongside the + // drawer tree, purely so we can use its Trigger component (which supports + // touch for free). The resulting behavior and DOM tree looks exactly the + // same as if Vaul provided a long-press trigger of its own, so I think + // this is fine. + return drawer ? ( + <> + {trigger} + + + + + {children} + + + + + ) : ( + + {trigger} + + + {children} + + + + ); +}; diff --git a/src/components/Menu/ToggleMenuItem.tsx b/src/components/Menu/ToggleMenuItem.tsx index 71789366..3e5e4ff2 100644 --- a/src/components/Menu/ToggleMenuItem.tsx +++ b/src/components/Menu/ToggleMenuItem.tsx @@ -23,7 +23,7 @@ type Props = Pick< ComponentProps, "className" | "Icon" | "label" | "onSelect" > & - Omit, "id" | "children">; + Omit, "id" | "children" | "onSelect">; /** * A menu item with a toggle control. Clicking anywhere on the surface will diff --git a/src/index.ts b/src/index.ts index 25be450b..1a3b1aca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ export { Badge } from "./components/Badge/Badge"; export { Button, IconButton } from "./components/Button"; export { Body } from "./components/Typography/Body"; export { Text } from "./components/Typography/Text"; +export { ContextMenu } from "./components/Menu/ContextMenu"; export { Glass } from "./components/Glass/Glass"; export { Heading, diff --git a/vite.config.ts b/vite.config.ts index 4653a7c9..61830aba 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ "classnames", "graphemer", "vaul", + "@radix-ui/react-context-menu", "@radix-ui/react-dropdown-menu", "@radix-ui/react-form", "@radix-ui/react-tooltip", diff --git a/yarn.lock b/yarn.lock index 87e90e9d..3e3231c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1747,6 +1747,19 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-context-menu@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context-menu/-/react-context-menu-2.1.5.tgz#1bdbd72761439f9166f75dc4598f276265785c83" + integrity sha512-R5XaDj06Xul1KGb+WP8qiOh7tKJNz2durpLBXAGZjSVtctcRFCuEvy2gtMwRJGePwQQE5nV77gs4FwRi8T+r2g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-menu" "2.0.6" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-context@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"