import React, { useCallback, useContext, useEffect, useMemo } from 'react'; import styled from 'styled-components'; import { IconButton } from '../lib/components'; import { colors } from '../lib/foundations'; import { useBoolean, useStyledRef } from '../lib/utility-hooks'; import { smallText } from './common-styles'; import { BackAction } from './KeyboardNavigation'; const BORDER_WIDTH = 1; const PADDING_VERTICAL = 10; const ITEM_HEIGHT = 22; type Alignment = 'left' | 'right'; type Direction = 'up' | 'down'; interface MenuContext { getTriggerBounds: () => DOMRect; toggleVisibility: () => void; hide: () => void; visible: boolean; } const menuContext = React.createContext({ getTriggerBounds: () => { throw new Error('No trigger bounds available'); }, toggleVisibility: () => { throw new Error('toggleVisibility not defined'); }, hide: () => { throw new Error('hide not defined'); }, visible: false, }); const StyledMenuContainer = styled.div({ position: 'relative', padding: '8px 4px', display: 'flex', justifyContent: 'center', }); export function ContextMenuContainer(props: React.PropsWithChildren) { const ref = useStyledRef(); const [visible, , hide, toggleVisibility] = useBoolean(false); const getTriggerBounds = useCallback(() => { if (ref.current === null) { throw new Error('No trigger bounds available'); } return ref.current.getBoundingClientRect(); }, [ref]); const contextValue = useMemo( () => ({ getTriggerBounds, toggleVisibility, visible, hide, }), [getTriggerBounds, hide, toggleVisibility, visible], ); const clickOutsideListener = useCallback( (event: MouseEvent) => { if ( visible && event.target !== null && ref.current?.contains(event.target as HTMLElement) === false ) { hide(); } }, [hide, ref, visible], ); useEffect(() => { document.addEventListener('click', clickOutsideListener, true); return () => document.removeEventListener('click', clickOutsideListener, true); }, [clickOutsideListener]); return ( {props.children} ); } export function ContextMenuTrigger() { const { toggleVisibility } = useContext(menuContext); return ( ); } interface StyledMenuProps { $direction: Direction; $align: Alignment; } const StyledMenu = styled.div((props) => { const oppositeSide = 'calc(100% - 8px)'; const iconMargin = '12px'; return { position: 'absolute', top: props.$direction === 'up' ? 'auto' : oppositeSide, bottom: props.$direction === 'up' ? oppositeSide : 'auto', left: props.$align === 'left' ? iconMargin : 'auto', right: props.$align === 'left' ? 'auto' : iconMargin, padding: '7px 4px', background: colors.blue40, border: `1px solid ${colors.darkBlue}`, borderRadius: '8px', zIndex: 1, }; }); const StyledMenuItem = styled.button(smallText, (props) => ({ minWidth: '110px', padding: '1px 10px 2px', lineHeight: `${ITEM_HEIGHT}px`, background: colors.transparent, border: 'none', textAlign: 'left', color: props.disabled ? colors.whiteAlpha40 : colors.white, '&&:hover': { background: props.disabled ? colors.transparent : colors.blue, }, })); const StyledSeparator = styled.hr({ height: '1px', border: 'none', backgroundColor: colors.darkBlue, margin: '4px 9px', }); type ContextMenuItemItem = { type: 'item'; label: string; disabled?: boolean; onClick: () => void; }; type ContextMenuSeparator = { type: 'separator' }; export type ContextMenuItem = ContextMenuItemItem | ContextMenuSeparator; interface MenuProps { items: Array; align: Alignment; } export function ContextMenu(props: MenuProps) { const { getTriggerBounds, visible, hide } = useContext(menuContext); if (!visible) { return null; } const triggerBounds = getTriggerBounds(); const direction = calculateDirection(visible, triggerBounds, props.items.length); return ( {props.items.map((item, i) => item.type === 'separator' ? ( ) : ( ), )} ); } function calculateDirection( visible: boolean, triggerBounds: DOMRect, itemsLength: number, ): Direction { if (visible) { const extraSpace = 2 * (BORDER_WIDTH + PADDING_VERTICAL); const downwardsStartPosition = triggerBounds.y + triggerBounds.height; const downwardsEndPosition = downwardsStartPosition + itemsLength * ITEM_HEIGHT + extraSpace; return downwardsEndPosition < window.innerHeight ? 'down' : 'up'; } else { return 'down'; } } interface ContextMenuItemRowProps { item: ContextMenuItemItem; closeMenu: () => void; } function ContextMenuItemRow(props: ContextMenuItemRowProps) { const { closeMenu } = props; const onClick = useCallback(() => { if (!props.item.disabled) { closeMenu(); props.item.onClick(); } }, [closeMenu, props.item]); return ( {props.item.label} ); }