import * as React from "react"; import { Portal } from "react-portal"; import { Menu } from "reakit/Menu"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import useMenuHeight from "~/hooks/useMenuHeight"; import usePrevious from "~/hooks/usePrevious"; import { fadeIn, fadeAndSlideUp, fadeAndSlideDown, mobileContextMenu, } from "~/styles/animations"; export type Placement = | "auto-start" | "auto" | "auto-end" | "top-start" | "top" | "top-end" | "right-start" | "right" | "right-end" | "bottom-end" | "bottom" | "bottom-start" | "left-end" | "left" | "left-start"; type Props = { "aria-label": string; visible?: boolean; placement?: Placement; animating?: boolean; children: React.ReactNode; unstable_disclosureRef?: React.RefObject; onOpen?: () => void; onClose?: () => void; hide?: () => void; }; export default function ContextMenu({ children, onOpen, onClose, ...rest }: Props) { const previousVisible = usePrevious(rest.visible); const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef); const backgroundRef = React.useRef(); React.useEffect(() => { if (rest.visible && !previousVisible) { if (onOpen) { onOpen(); } } if (!rest.visible && previousVisible) { if (onClose) { onClose(); } } }, [onOpen, onClose, previousVisible, rest.visible]); // Perf win – don't render anything until the menu has been opened if (!rest.visible && !previousVisible) { return null; } // sets the menu height based on the available space between the disclosure/ // trigger and the bottom of the window return ( <> {(props) => { // kind of hacky, but this is an effective way of telling which way // the menu will _actually_ be placed when taking into account screen // positioning. // @ts-expect-error ts-migrate(2532) FIXME: Object is possibly 'undefined'. const topAnchor = props.style.top === "0"; // @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message const rightAnchor = props.placement === "bottom-end"; return ( {rest.visible || rest.animating ? children : null} ); }} {(rest.visible || rest.animating) && ( )} ); } export const Backdrop = styled.div` animation: ${fadeIn} 200ms ease-in-out; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: ${(props) => props.theme.backdrop}; z-index: ${(props) => props.theme.depths.menu - 1}; ${breakpoint("tablet")` display: none; `}; `; export const Position = styled.div` position: absolute; z-index: ${(props) => props.theme.depths.menu}; ${breakpoint("mobile", "tablet")` position: fixed !important; transform: none !important; top: auto !important; right: 8px !important; bottom: 16px !important; left: 8px !important; `}; `; export const Background = styled.div<{ topAnchor?: boolean; rightAnchor?: boolean; }>` animation: ${mobileContextMenu} 200ms ease; transform-origin: 50% 100%; max-width: 100%; background: ${(props) => props.theme.menuBackground}; border-radius: 6px; padding: 6px 0; min-width: 180px; min-height: 44px; overflow: hidden; overflow-y: auto; max-height: 75vh; pointer-events: all; font-weight: normal; @media print { display: none; } ${breakpoint("tablet")` animation: ${(props: any) => props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease; transform-origin: ${(props: any) => (props.rightAnchor ? "75%" : "25%")} 0; max-width: 276px; background: ${(props: any) => props.theme.menuBackground}; box-shadow: ${(props: any) => props.theme.menuShadow}; `}; `;