/* Copyright 2023 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import React, { type FC, type ReactNode, useMemo, useEffect, useState, } from "react"; import { Root, Trigger, Portal, Content, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal, } 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, type MenuData, type MenuItemWrapperProps, type SubMenuWrapperProps, } from "./MenuContext"; import { DrawerMenu } from "./DrawerMenu"; import { getPlatform } from "../../utils/platform"; interface Props { /** * CSS classes for the menu. */ className?: string; /** * The menu title. This can be hidden with `showTitle={false}` in which case it will only * be a label for screen readers. */ title: string; /** * Controls whether the title is displayed (see `title` prop). Titles are only displayed on * web: on mobile, this parameter is ignored. */ showTitle?: boolean; /** * 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} ); /** Duration of the parent menu's slide-in animation (ms). */ const MENU_ANIMATION_DURATION = 180; const DropdownSubMenuWrapper: FC = ({ trigger, children, open: openProp, onOpenChange, }) => { // When the submenu is programmatically opened at the same time as the parent // menu (e.g. open={true} on mount), the parent is still mid-animation and // the trigger position hasn't settled. Defer the open so the submenu // positions correctly after the parent animation completes. const [deferredOpen, setDeferredOpen] = useState(false); useEffect(() => { if (openProp) { const timer = setTimeout( () => setDeferredOpen(true), MENU_ANIMATION_DURATION, ); return () => clearTimeout(timer); } else { setDeferredOpen(false); } }, [openProp]); const open = openProp ? deferredOpen : openProp; return ( {trigger} {children} ); }; /** * A menu opened by pressing a button. */ export const Menu: FC = ({ className, title, showTitle = true, 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, SubMenuWrapper: drawer ? null : DropdownSubMenuWrapper, onOpenChange, }), [onOpenChange], ); const children = ( {childrenProp} ); return drawer ? ( {trigger} {children} ) : ( {trigger} {children} ); };