Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should check which customization is used, either if old one (ui.context-menu) keep this if the new one (default for us) not use this piece of code

// 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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ContextMenu } from '@ohif/ui';
import { DialogContextMenu } from '@ohif/ui-next';

export default {
'ui.contextMenu': ContextMenu,
'ui.contextMenu': DialogContextMenu,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please reach out to Bill about adding a customization to retain the old context menu for the next version? We could call this ui-next.contextMenu to keep it separate, and then remove ui.contextMenu later on.

};
304 changes: 304 additions & 0 deletions platform/ui-next/src/components/ContextMenu/DialogContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => boolean;
commands?: unknown[];
action?: (item: unknown, props: unknown) => void;
}>;
selector?: (props: Record<string, unknown>) => 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<string, unknown>;

/** 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<string, unknown>
) => void;
}

/**
* Recursively renders menu items, supporting nested submenus via hover
*/
const MenuItemRenderer: React.FC<{
item: DialogContextMenuItem;
menuProps: DialogContextMenuProps;
menus?: DialogContextMenuDefinition[];
selectorProps?: Record<string, unknown>;
event?: Event;
}> = ({ item, menuProps, menus, selectorProps, event }) => {
const [isSubMenuOpen, setIsSubMenuOpen] = React.useState(false);
const [subMenuItems, setSubMenuItems] = React.useState<DialogContextMenuItem[] | null>(null);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | 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 (
<div
ref={refs.setReference}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
data-cy="context-menu-item"
role="menuitem"
tabIndex={0}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-base outline-none',
'hover:bg-accent hover:text-accent-foreground',
'focus-visible:bg-accent focus-visible:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
)}
onClick={handleClick}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
<span className="flex-1">{item.label}</span>
<span className="ml-1 flex h-3 w-3 shrink-0 items-center justify-center">
{item.iconRight && (
<Icons.ByName
name={item.iconRight}
className="text-muted-foreground h-3 w-3"
/>
)}
</span>
</div>

{/* Submenu - positioned by Floating UI for viewport-aware placement */}
{hasSubMenu && isSubMenuOpen && subMenuItems && subMenuItems.length > 0 && (
<div
ref={refs.setFloating}
style={floatingStyles}
className={cn(
'bg-popover text-popover-foreground z-50 min-w-40 rounded-md border border-input p-1 shadow-lg',
'animate-in fade-in-0 zoom-in-95'
)}
onMouseEnter={handleSubMenuMouseEnter}
onMouseLeave={handleSubMenuMouseLeave}
>
{subMenuItems.map((subItem, subIndex) => (
<MenuItemRenderer
key={subIndex}
item={subItem}
menuProps={menuProps}
menus={menus}
selectorProps={selectorProps}
event={event}
/>
))}
</div>
)}
</div>
);
};

/**
* 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<DialogContextMenuProps> = ({
items,
menus,
selectorProps,
event,
...props
}) => {
if (!items || items.length === 0) {
return null;
}

const menuProps: DialogContextMenuProps = { items, menus, selectorProps, event, ...props };

return (
<div
data-cy="context-menu"
role="menu"
className={cn(
'bg-popover text-popover-foreground z-50 min-w-40 rounded-md border border-input p-1 shadow-md',
'animate-in fade-in-0 zoom-in-95'
)}
onContextMenu={e => e.preventDefault()}
>
{items.map((item, index) => (
<MenuItemRenderer
key={index}
item={item}
menuProps={menuProps}
menus={menus}
selectorProps={selectorProps}
event={event}
/>
))}
</div>
);
};

DialogContextMenu.displayName = 'DialogContextMenu';

export default DialogContextMenu;
13 changes: 13 additions & 0 deletions platform/ui-next/src/components/ContextMenu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,4 +40,9 @@ export {
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
// Dialog-based adapter for UIDialogService integration
DialogContextMenu,
type DialogContextMenuProps,
type DialogContextMenuItem,
type DialogContextMenuDefinition,
};
1 change: 1 addition & 0 deletions platform/ui-next/src/components/Icons/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Loading