diff --git a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx index 2827790c0d6..9b47277dc87 100644 --- a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx +++ b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx @@ -102,26 +102,23 @@ export default class ContextMenuController { this.services.uiDialogService.hide('context-menu'); }, - /** - * Displays a sub-menu, removing this menu - * @param {*} item - * @param {*} itemRef - * @param {*} subProps - */ - onShowSubMenu: (item, itemRef, subProps) => { - if (!itemRef.subMenu) { - console.warn('No submenu defined for', item, itemRef, subProps); - return; - } - this.showContextMenu( - { - ...contextMenuProps, - menuId: itemRef.subMenu, - }, - viewportElement, - defaultPointsPosition - ); - }, + // NOTE: onShowSubMenu removed - DialogContextMenu handles submenus inline + // via Floating UI using the `menus` prop passed above. + // + // onShowSubMenu: (item, itemRef, subProps) => { + // if (!itemRef.subMenu) { + // console.warn('No submenu defined for', item, itemRef, subProps); + // return; + // } + // this.showContextMenu( + // { + // ...contextMenuProps, + // menuId: itemRef.subMenu, + // }, + // viewportElement, + // defaultPointsPosition + // ); + // }, // Default is to run the specified commands. onDefault: (item, itemRef, subProps) => { diff --git a/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts index 08a5ac66f9a..8c9953fcede 100644 --- a/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts +++ b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts @@ -154,7 +154,7 @@ export function adaptItem(item: MenuItem, subProps: ContextMenuProps): ContextMe }; if (item.actionType === 'ShowSubMenu' && !newItem.iconRight) { - newItem.iconRight = 'chevron-down'; + newItem.iconRight = 'chevron-right'; } if (!item.action) { newItem.action = (itemRef, componentProps) => { diff --git a/extensions/default/src/customizations/contextMenuUICustomization.ts b/extensions/default/src/customizations/contextMenuUICustomization.ts index 4366df89c6e..d519706df58 100644 --- a/extensions/default/src/customizations/contextMenuUICustomization.ts +++ b/extensions/default/src/customizations/contextMenuUICustomization.ts @@ -1,5 +1,5 @@ -import { ContextMenu } from '@ohif/ui'; +import { DialogContextMenu } from '@ohif/ui-next'; export default { - 'ui.contextMenu': ContextMenu, + 'ui.contextMenu': DialogContextMenu, }; diff --git a/platform/ui-next/src/components/ContextMenu/DialogContextMenu.tsx b/platform/ui-next/src/components/ContextMenu/DialogContextMenu.tsx new file mode 100644 index 00000000000..3b04600f9bf --- /dev/null +++ b/platform/ui-next/src/components/ContextMenu/DialogContextMenu.tsx @@ -0,0 +1,304 @@ +import * as React from 'react'; +import { useFloating, flip, shift, offset } from '@floating-ui/react-dom'; +import { cn } from '../../lib/utils'; +import { Icons } from '../Icons'; +import type { ContextMenuItem as ContextMenuItemType } from '../../types/ContextMenuItem'; + +/** + * Extended menu item type that includes submenu-related properties + * from the ContextMenuItemsBuilder. + * + * Note: This should stay in sync with MenuItem from + * extensions/default/src/CustomizableContextMenu/types.ts + */ +export interface DialogContextMenuItem extends ContextMenuItemType { + subMenu?: string; + actionType?: string; + delegating?: boolean; + value?: unknown; + element?: HTMLElement; +} + +/** + * Menu definition type for submenu lookup. + * + * Note: This should stay in sync with Menu from + * extensions/default/src/CustomizableContextMenu/types.ts + */ +export interface DialogContextMenuDefinition { + id: string; + items: Array<{ + label?: string; + subMenu?: string; + actionType?: string; + delegating?: boolean; + selector?: (props: Record) => boolean; + commands?: unknown[]; + action?: (item: unknown, props: unknown) => void; + }>; + selector?: (props: Record) => boolean; +} + +/** + * Props passed to DialogContextMenu from ContextMenuController via UIDialogService + */ +export interface DialogContextMenuProps { + /** Array of menu items to display */ + items?: DialogContextMenuItem[]; + + /** Props used for menu/item selection */ + selectorProps?: Record; + + /** Available menus for submenu lookup */ + menus?: DialogContextMenuDefinition[]; + + /** The triggering event */ + event?: Event; + + /** Current submenu ID */ + subMenu?: string; + + /** Event detail data */ + eventData?: unknown; + + /** Callback to close the menu */ + onClose?: () => void; + + /** Default action callback */ + onDefault?: ( + item: DialogContextMenuItem, + itemRef: DialogContextMenuItem, + subProps: Record + ) => void; +} + +/** + * Recursively renders menu items, supporting nested submenus via hover + */ +const MenuItemRenderer: React.FC<{ + item: DialogContextMenuItem; + menuProps: DialogContextMenuProps; + menus?: DialogContextMenuDefinition[]; + selectorProps?: Record; + event?: Event; +}> = ({ item, menuProps, menus, selectorProps, event }) => { + const [isSubMenuOpen, setIsSubMenuOpen] = React.useState(false); + const [subMenuItems, setSubMenuItems] = React.useState(null); + const timeoutRef = React.useRef | null>(null); + + // Floating UI for smart submenu positioning + const { refs, floatingStyles } = useFloating({ + placement: 'right-start', + middleware: [ + offset(4), // 4px gap between parent and submenu + flip({ + fallbackPlacements: ['left-start', 'right-end', 'left-end'], + padding: 8, + }), + shift({ + padding: 8, + }), + ], + }); + + // Determine if this item should show a nested submenu + const hasSubMenu = + !!menus && item.subMenu && item.actionType === 'ShowSubMenu' && !item.delegating; + + const handleMouseEnter = React.useCallback(() => { + if (!hasSubMenu || !menus) { + return; + } + + // Clear any pending close timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Find submenu items + const subMenu = menus.find(menu => menu.id === item.subMenu); + if (subMenu?.items) { + // Adapt submenu items similar to ContextMenuItemsBuilder.adaptItem + const adaptedItems = subMenu.items + .filter(subItem => !subItem.selector || subItem.selector(selectorProps || {})) + .map(subItem => { + const adapted: DialogContextMenuItem = { + ...subItem, + label: subItem.label || '', + action: + subItem.action || + ((adaptedItemRef, componentProps) => { + componentProps.onClose?.(); + const actionHandler = componentProps[`on${subItem.actionType || 'Default'}`]; + if (actionHandler) { + actionHandler.call(componentProps, adapted, subItem, { selectorProps, event }); + } + }), + }; + + if (subItem.actionType === 'ShowSubMenu' && !adapted.iconRight) { + adapted.iconRight = 'chevron-right'; + } + + return adapted; + }); + + setSubMenuItems(adaptedItems); + setIsSubMenuOpen(true); + } + }, [hasSubMenu, menus, item.subMenu, selectorProps, event]); + + const handleMouseLeave = React.useCallback(() => { + // Delay closing to allow moving to submenu + timeoutRef.current = setTimeout(() => { + setIsSubMenuOpen(false); + }, 50); + }, []); + + const handleSubMenuMouseEnter = React.useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, []); + + const handleSubMenuMouseLeave = React.useCallback(() => { + setIsSubMenuOpen(false); + }, []); + + React.useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const handleClick = React.useCallback(() => { + if (hasSubMenu) { + // Toggle submenu on click (mobile-friendly) + setIsSubMenuOpen(prev => !prev); + return; + } + item.action(item, menuProps); + }, [hasSubMenu, item, menuProps]); + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} + > + {item.label} + + {item.iconRight && ( + + )} + +
+ + {/* Submenu - positioned by Floating UI for viewport-aware placement */} + {hasSubMenu && isSubMenuOpen && subMenuItems && subMenuItems.length > 0 && ( +
+ {subMenuItems.map((subItem, subIndex) => ( + + ))} +
+ )} +
+ ); +}; + +/** + * DialogContextMenu - A context menu component designed to work with UIDialogService. + * + * This component serves as an adapter between the items-array API used by + * ContextMenuController and the ui-next styling system. It renders menu items + * with styling consistent with Radix UI context menus while supporting the + * imperative show/hide pattern used by UIDialogService. + * + * Features: + * - Matches ui-next ContextMenuContent/ContextMenuItem styling + * - Supports nested submenus via hover with Floating UI for smart positioning + * - Automatically flips submenu placement when near viewport edges + * - Maintains data-cy attributes for Cypress testing + * - Calls item.action(item, props) on click + * - Supports iconRight for submenu indicators + */ +export const DialogContextMenu: React.FC = ({ + items, + menus, + selectorProps, + event, + ...props +}) => { + if (!items || items.length === 0) { + return null; + } + + const menuProps: DialogContextMenuProps = { items, menus, selectorProps, event, ...props }; + + return ( +
e.preventDefault()} + > + {items.map((item, index) => ( + + ))} +
+ ); +}; + +DialogContextMenu.displayName = 'DialogContextMenu'; + +export default DialogContextMenu; diff --git a/platform/ui-next/src/components/ContextMenu/index.ts b/platform/ui-next/src/components/ContextMenu/index.ts index 36ed0d4ccab..255e94da15a 100644 --- a/platform/ui-next/src/components/ContextMenu/index.ts +++ b/platform/ui-next/src/components/ContextMenu/index.ts @@ -16,7 +16,15 @@ import { ContextMenuRadioGroup, } from './ContextMenu'; +import { + DialogContextMenu, + type DialogContextMenuProps, + type DialogContextMenuItem, + type DialogContextMenuDefinition, +} from './DialogContextMenu'; + export { + // Radix-based primitives for declarative context menus ContextMenu, ContextMenuTrigger, ContextMenuContent, @@ -32,4 +40,9 @@ export { ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, + // Dialog-based adapter for UIDialogService integration + DialogContextMenu, + type DialogContextMenuProps, + type DialogContextMenuItem, + type DialogContextMenuDefinition, }; diff --git a/platform/ui-next/src/components/Icons/Icons.tsx b/platform/ui-next/src/components/Icons/Icons.tsx index 5ff25c73c0d..faed769e1cd 100644 --- a/platform/ui-next/src/components/Icons/Icons.tsx +++ b/platform/ui-next/src/components/Icons/Icons.tsx @@ -690,6 +690,7 @@ export const Icons = { 'icon-multiple-patients': (props: IconProps) => MultiplePatients(props), 'icon-patient': (props: IconProps) => Patient(props), 'chevron-down': (props: IconProps) => ChevronOpen(props), + 'chevron-right': (props: IconProps) => Icons.ChevronRight(props), 'tool-length': (props: IconProps) => ToolLength(props), 'tool-3d-rotate': (props: IconProps) => Tool3DRotate(props), 'tool-angle': (props: IconProps) => ToolAngle(props), diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 5fc7af79029..8cf6ceedc27 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -11,6 +11,26 @@ import { CommandShortcut, CommandSeparator, } from './Command'; +import { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, + DialogContextMenu, + type DialogContextMenuProps, + type DialogContextMenuItem, +} from './ContextMenu'; import { Dialog, DialogPortal, @@ -214,6 +234,22 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, + DialogContextMenu, Onboarding, Select, SelectTrigger,