ohif-viewer/platform/ui-next/src/components/AllInOneMenu/Menu.tsx
Joe Boccanfuso eb6b814fc9
fix(window-level-action-menu): Window level menu no longer needs two clicks to open after it is used (#5711)
The window level menu's logic for when it is opened and closed now also runs when the AllInOneMenu triggers the open or close.
Using refs to store the latest function references in the AllInOneMenu component to avoid triggering a useEffect when the functions change.
Removed an unused element prop from the WindowLevelActionMenu component.
2026-01-14 16:50:24 -05:00

232 lines
7.2 KiB
TypeScript

import React, { createContext, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import DividerItem from './DividerItem';
import PanelSelector from './PanelSelector';
import classNames from 'classnames';
import BackItem from './BackItem';
/**
* The vertical direction that the menu will be opened/used with.
*
* A TopToBottom menu would be used for cases where the menu is opened "near"
* the top edge of its container. Likewise a BottomToTop menu would be used
* for cases where the menu is opened "near" the bottom edge of its container.
*
* See IconMenu for more information.
*/
export enum VerticalDirection {
TopToBottom,
BottomToTop,
}
/**
* The horizontal direction that the menu is opened/used with.
* This direction dictates the general direction sub-menus and
* back-to-menus are opened with. For example, a RightToLeft menu
* will have sub-menu items indicated with a left pointing chevron
* and aligned with the left edge of the menu. Similarly back-to items of a
* RightToLeft menu are indicated with a right pointing chevron and
* aligned with the right edge of the menu.
*
* It is also worth noting that a LeftToRight menu would be used for
* cases where a menu is opened "near" the left edge of its container.
* Likewise, a RightToLeft menu would be used for cases where a menu is opened
* "near" the right edge of its container.
*
* See IconMenu for more information.
*/
export enum HorizontalDirection {
LeftToRight,
RightToLeft,
}
export interface MenuProps {
menuStyle?: unknown;
menuClassName?: string;
isVisible?: boolean;
preventHideMenu?: boolean;
backLabel?: string;
headerComponent?: ReactNode;
showHeaderDivider?: boolean;
activePanelIndex?: number;
onVisibilityChange?: (isVisible: boolean) => void;
// New props that can be used as alternatives to horizontalDirection and verticalDirection
align?: 'start' | 'end' | 'center';
side?: 'top' | 'bottom' | 'left' | 'right';
children: ReactNode;
}
type MenuContextProps = {
showSubMenu: (subMenuProps: MenuProps) => void;
hideMenu: () => void;
addItemPanel: (index: number, label: string) => void;
horizontalDirection: HorizontalDirection;
verticalDirection?: VerticalDirection;
activePanelIndex: number;
};
type MenuPathState = {
props: MenuProps;
activePanelIndex: number;
};
export const MenuContext = createContext<MenuContextProps>(null);
const Menu = (props: MenuProps) => {
const {
isVisible,
onVisibilityChange,
activePanelIndex,
preventHideMenu,
menuClassName,
menuStyle,
align,
side,
} = props;
// Derive horizontalDirection and verticalDirection from align and side if provided
let horizontalDirection = HorizontalDirection.LeftToRight;
let verticalDirection = VerticalDirection.BottomToTop;
if (align !== undefined) {
horizontalDirection =
align === 'start' ? HorizontalDirection.LeftToRight : HorizontalDirection.RightToLeft;
}
if (side !== undefined) {
verticalDirection =
side === 'bottom' ? VerticalDirection.TopToBottom : VerticalDirection.BottomToTop;
}
const [isMenuVisible, setIsMenuVisible] = useState(isVisible);
// The menuPath is an array consisting of this top Menu and every SubMenu
// that has been traversed/opened by the user with the last item in the array
// being the current (sub)menu that is currently visible. This allows for the previously
// viewed menus to be returned to via the Back button at the top of the menu.
const [menuPath, setMenuPath] = useState<Array<MenuPathState>>([
{ props, activePanelIndex: activePanelIndex || 0 },
]);
const [itemPanelLabels, setItemPanelLabels] = useState<Array<string>>([]);
// Store latest function references in refs so we can use them in effects
// without triggering re-runs when they change
const onVisibilityChangeRef = useRef(onVisibilityChange);
const preventHideMenuRef = useRef(preventHideMenu);
// Keep refs up to date
useEffect(() => {
onVisibilityChangeRef.current = onVisibilityChange;
}, [onVisibilityChange]);
useEffect(() => {
preventHideMenuRef.current = preventHideMenu;
}, [preventHideMenu]);
// If the props change for the this top level menu then we have to update the menu path
// because the props to be rendered are maintained in the state.
useEffect(() => {
setMenuPath(menuPath => [
{ props, activePanelIndex: activePanelIndex || 0 },
...menuPath.slice(1),
]);
}, [activePanelIndex, props]);
const hideMenu = useCallback(() => {
if (preventHideMenuRef.current) {
return;
}
setMenuPath(path => [path[0]]);
setItemPanelLabels([]);
setIsMenuVisible(false);
onVisibilityChangeRef.current?.(false);
}, []);
// Only run this effect when isVisible changes, not when functions change.
// Note that hideMenu is stable with no dependencies and thus will not trigger the useEffect to run.
useEffect(() => {
if (isVisible) {
setIsMenuVisible(isVisible);
onVisibilityChangeRef.current?.(isVisible);
} else {
hideMenu();
}
}, [isVisible, hideMenu]);
const showSubMenu = useCallback((subMenuProps: MenuProps) => {
setMenuPath(path => {
return [
...path,
{ props: subMenuProps, activePanelIndex: subMenuProps.activePanelIndex || 0 },
];
});
setItemPanelLabels([]);
}, []);
const addItemPanel = useCallback((index, label) => {
setItemPanelLabels(labels => {
return [...labels.slice(0, index), label, ...labels.slice(index + 1, labels.length)];
});
}, []);
const onActivePanelIndexChange = useCallback(index => {
setMenuPath(path => {
return [
...path.slice(0, path.length - 1),
{ ...path[path.length - 1], activePanelIndex: index },
];
});
}, []);
const onBackClick = useCallback(() => {
setMenuPath(path => [...path.slice(0, path.length - 1)]);
setItemPanelLabels([]);
}, []);
const { props: currentMenuProps, activePanelIndex: currentMenuActivePanelIndex } =
menuPath[menuPath.length - 1];
return (
<>
<MenuContext.Provider
value={{
showSubMenu,
hideMenu,
addItemPanel,
activePanelIndex: currentMenuActivePanelIndex,
horizontalDirection,
verticalDirection,
}}
>
{isMenuVisible && (
<div
className={classNames(
'bg-popover/90 text-foreground flex select-none flex-col rounded px-1 py-1.5',
menuClassName
)}
style={menuStyle}
>
{menuPath.length > 1 && (
<BackItem
backLabel={menuPath[menuPath.length - 2].props.backLabel}
onBackClick={onBackClick}
/>
)}
{itemPanelLabels.length > 1 && (
<PanelSelector
panelLabels={itemPanelLabels}
activeIndex={currentMenuActivePanelIndex}
onActiveIndexChange={onActivePanelIndexChange}
></PanelSelector>
)}
{currentMenuProps.headerComponent}
{currentMenuProps.showHeaderDivider && <DividerItem />}
{currentMenuProps.children}
</div>
)}
</MenuContext.Provider>
</>
);
};
export default Menu;