This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Icon } from '@ohif/ui';
import DividerItem from './DividerItem';
type BackItemProps = {
backLabel?: string;
onBackClick: () => void;
};
const BackItem = ({ backLabel, onBackClick }: BackItemProps) => {
return (
<>
<div
className="all-in-one-menu-item all-in-one-menu-item-effects"
onClick={onBackClick}
>
<Icon name="content-prev"></Icon>
<div className="pl-2">{backLabel || 'Back to Display Options'}</div>
</div>
<DividerItem></DividerItem>
</>
);
};
export default BackItem;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const DividerItem = () => {
return (
<div className="flex h-3.5 shrink-0 items-center px-2">
<div className="bg-primary-dark h-[2px] w-full"></div>
</div>
);
};
export default DividerItem;

View File

@@ -0,0 +1,13 @@
import React, { ReactNode } from 'react';
type HeaderItemProps = {
children: ReactNode;
};
const HeaderItem = ({ children }: HeaderItemProps) => {
return (
<div className="text-aqua-pale mx-2 flex h-6 shrink-0 items-center text-[11px]">{children}</div>
);
};
export default HeaderItem;

View File

@@ -0,0 +1,82 @@
import React, { useCallback, useEffect, useState } from 'react';
import OutsideClickHandler from 'react-outside-click-handler';
import { MenuProps } from './Menu';
import getIcon from '../Icon/getIcon';
import classNames from 'classnames';
import { AllInOneMenu } from '..';
export interface IconMenuProps extends MenuProps {
icon: string;
iconClassName?: string;
horizontalDirection?: AllInOneMenu.HorizontalDirection;
verticalDirection?: AllInOneMenu.VerticalDirection;
menuKey?: number | string;
}
/**
* An IconMenu allows for a div wrapped icon to be clicked to show and hide
* an AllInOneMenu.Menu. Based on the direction(s) specified, the menu is
* positioned relative to the icon.
*
* HorizontalDirection.LeftToRight - the left edges of the icon and menu are aligned
* HorizontalDirection.RightRoLeft - the right edges of the icon and menu are aligned
* VerticalDirection.TopToBottom - the top edge of the menu appears directly below the bottom edge of the icon
* VerticalDirection.BottomToTop - the bottom edge of the menu appears directly above the top edge of the icon
*
* For example, if an IconMenu were situated in the bottom-left corner of a container,
* it would be best to use BottomToTop and LeftToRight directions for it.
*/
export default function IconMenu({
icon,
iconClassName,
horizontalDirection,
verticalDirection,
children,
backLabel,
menuClassName,
menuStyle,
onVisibilityChange,
menuKey,
}: IconMenuProps) {
const [isMenuVisible, setIsMenuVisible] = useState(false);
const toggleMenuVisibility = useCallback(() => setIsMenuVisible(isVisible => !isVisible), []);
return (
<OutsideClickHandler
onOutsideClick={toggleMenuVisibility}
disabled={!isMenuVisible}
>
<div className="relative">
<div
className={iconClassName}
onClick={toggleMenuVisibility}
>
{getIcon(icon)}
</div>
<AllInOneMenu.Menu
key={menuKey}
isVisible={isMenuVisible}
backLabel={backLabel}
menuClassName={classNames(
menuClassName,
'absolute',
verticalDirection === AllInOneMenu.VerticalDirection.TopToBottom
? 'top-[100%]'
: 'bottom-[100%]',
horizontalDirection === AllInOneMenu.HorizontalDirection.LeftToRight
? 'left-0'
: 'right-0'
)}
menuStyle={menuStyle}
onVisibilityChange={isVis => {
setIsMenuVisible(isVis);
onVisibilityChange?.(isVis);
}}
horizontalDirection={horizontalDirection}
>
{children}
</AllInOneMenu.Menu>
</div>
</OutsideClickHandler>
);
}

View File

@@ -0,0 +1,45 @@
import React, { ReactNode, useCallback, useContext } from 'react';
import { MenuContext } from './Menu';
type ItemProps = {
label: string;
secondaryLabel?: string;
icon?: ReactNode;
onClick?: () => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
rightIcon?: ReactNode;
};
const Item = ({
label,
secondaryLabel,
icon,
rightIcon,
onClick,
onMouseEnter,
onMouseLeave,
}: ItemProps) => {
const { hideMenu } = useContext(MenuContext);
const onClickHandler = useCallback(() => {
hideMenu();
onClick?.();
}, [hideMenu, onClick]);
return (
<div
className="all-in-one-menu-item all-in-one-menu-item-effects"
onClick={onClickHandler}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{icon && <div className="pr-2">{icon}</div>}
<span>{label}</span>
{secondaryLabel != null && <span className="text-aqua-pale ml-[1ch]">{secondaryLabel}</span>}
{rightIcon && <div className="ml-auto">{rightIcon}</div>}
</div>
);
};
export default Item;

View File

@@ -0,0 +1,29 @@
import React, { ReactNode, useContext, useEffect } from 'react';
import { MenuContext } from './Menu';
type ItemPanelProps = {
label?: string;
index?: number;
children: ReactNode;
};
const ItemPanel = ({ label, index = 0, children }: ItemPanelProps) => {
const { addItemPanel, activePanelIndex } = useContext(MenuContext);
useEffect(() => {
addItemPanel(index, label);
}, []);
return (
activePanelIndex === index && (
<div
style={{ scrollbarGutter: 'auto' }}
className="ohif-scrollbar flex flex-col overflow-auto"
>
{children}
</div>
)
);
};
export default ItemPanel;

View File

@@ -0,0 +1,188 @@
import React, { createContext, ReactNode, useCallback, useEffect, useState } from 'react';
import './allInOneMenu.css';
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;
horizontalDirection?: HorizontalDirection;
children: ReactNode;
}
type MenuContextProps = {
showSubMenu: (subMenuProps: MenuProps) => void;
hideMenu: () => void;
addItemPanel: (index: number, label: string) => void;
horizontalDirection: HorizontalDirection;
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,
horizontalDirection = HorizontalDirection.LeftToRight,
} = props;
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>>([]);
const hideMenu = useCallback(() => {
if (preventHideMenu) {
return;
}
setMenuPath(path => [path[0]]);
setItemPanelLabels([]);
setIsMenuVisible(false);
onVisibilityChange?.(false);
}, [preventHideMenu, onVisibilityChange]);
useEffect(() => {
if (isVisible) {
setIsMenuVisible(isVisible);
onVisibilityChange?.(isVisible);
} else {
hideMenu();
}
}, [hideMenu, isVisible, onVisibilityChange]);
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,
}}
>
{isMenuVisible && (
<div
className={classNames(
'bg-secondary-dark flex select-none flex-col rounded px-1 py-1.5 text-white opacity-90',
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;

View File

@@ -0,0 +1,31 @@
import React, { ReactNode } from 'react';
import { ButtonGroup } from '../../components';
type PanelSelectorProps = {
panelLabels: Array<ReactNode>;
onActiveIndexChange: (index: number) => void;
activeIndex: number;
};
const PanelSelector = ({ panelLabels, onActiveIndexChange, activeIndex }: PanelSelectorProps) => {
const getButtons = () => {
return panelLabels.map((panelLabel, index) => {
return {
children: panelLabel,
key: index,
};
});
};
return (
<div className="mx-2 my-1 flex justify-center">
<ButtonGroup
buttons={getButtons()}
onActiveIndexChange={onActiveIndexChange}
defaultActiveIndex={activeIndex}
></ButtonGroup>
</div>
);
};
export default PanelSelector;

View File

@@ -0,0 +1,38 @@
import React, { useCallback, useContext } from 'react';
import { MenuContext, MenuProps } from './Menu';
import Icon from '../Icon';
export interface SubMenuProps extends MenuProps {
itemLabel: string;
onClick?: () => void;
itemIcon?: string;
}
const SubMenu = (props: SubMenuProps) => {
const { showSubMenu } = useContext(MenuContext);
const onClickHandler = useCallback(() => {
showSubMenu(props);
props.onClick?.();
}, [showSubMenu, props]);
return (
<div
className="all-in-one-menu-item all-in-one-menu-item-effects flex items-center"
onClick={onClickHandler}
>
{props.itemIcon && (
<Icon
name={props.itemIcon}
width="25px"
height="25px"
className="mr-2"
></Icon>
)}
<div className="mr-auto">{props.itemLabel}</div>
<Icon name="content-next"></Icon>
</div>
);
};
export default SubMenu;

View File

@@ -0,0 +1,129 @@
import { DividerItem, HeaderItem, Item, ItemPanel, Menu, SubMenu } from '..';
import InputRange from '../../InputRange/index.js';
import SwitchButton from '../../SwitchButton/index.js';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
export const argTypes = {
component: Menu,
title: 'Components/AllInOneMenu',
};
<Meta
title="Components/AllInOneMenu"
component={Menu}
/>
export const AllInOneMenuTemplate = args => (
<div className="w-80">
<Menu {...args}>
<ItemPanel>
<Item
label="Item 1"
key="0"
onClick={() => console.info('Item 1 clicked.')}
></Item>
<Item
label="Item 2"
secondaryLabel="Alt item 2"
key="1"
></Item>
<div
className="all-in-one-menu-item py-1"
style={{ flexBasis: 'content' }}
>
<div>Arbitrary item component:</div>
<InputRange
minValue={1}
maxValue={10}
value={5}
onChange={() => {}}
></InputRange>
</div>
<DividerItem key="2"></DividerItem>
<SubMenu
key="3"
backLabel="Back to Level 2"
itemLabel="Item 3 opens a sub menu"
onClick={() => console.info('Sub menu item clicked.')}
showHeaderDivider={true}
headerComponent={
<div className="all-in-one-menu-item flex w-full justify-center">
<SwitchButton label="Switch item in header" />
</div>
}
>
<ItemPanel
index={0}
label="Panel A"
>
<Item label="Panel A item"></Item>
</ItemPanel>
<ItemPanel
index={1}
label="Panel B"
>
<Item label="Panel B item"></Item>
<SubMenu
itemLabel="Opens a sub, sub menu"
headerComponent={<HeaderItem>Header for scrolling list of items</HeaderItem>}
>
<ItemPanel label="Sub sub Panel">
<Item label="Sub sub menu item 1"></Item>
<Item label="Sub sub menu item 2"></Item>
<Item label="Sub sub menu item 3"></Item>
<Item label="Sub sub menu item 4"></Item>
<Item label="Sub sub menu item 5"></Item>
<Item label="Sub sub menu item 6"></Item>
</ItemPanel>
</SubMenu>
</ItemPanel>
</SubMenu>
</ItemPanel>
</Menu>
</div>
);
<Heading
title="AllInOneMenu"
componentRelativePath="AllInOneMenu/AllInOneMenu.tsx"
/>
- [Overview](#overview)
- [Props](#props)
- [Contribute](#contribute)
## Overview
AllInOneMenu is a component that renders a menu with various menu items, sub-menus and
sub-components. The particular feature of the AllInOneMenu is its ability to render a sub-menu by
replacing the parent menu on screen. It also provides the ability to return to the parent menu with
a back menu item from the sub-menu. Furthermore, each menu level can be split into item panes - that
is several panes of menu items that are switched in and out of view like a tabbed pane.
<Canvas>
<Story
name="Overview"
args={{
className: 'max-h-[210px]',
isVisible: true,
preventHideMenu: true,
backLabel: 'Back to Level 1',
onVisibilityChange: (isVisible) => console.info(`The menu visibility: ${isVisible}`),
}}
>
{AllInOneMenuTemplate.bind({})}
</Story>
</Canvas>
## Props
<ArgsTable of={Menu} />
## Usage
## Contribute
<Footer componentRelativePath="AllInOneMenu/__stories__/AllInOneMenu.stories.mdx" />

View File

@@ -0,0 +1,11 @@
.all-in-one-menu-item {
@apply h-8 px-2 text-[14px] w-full;
display: flex;
align-items: center;
flex-shrink: 0;
line-height: 18px;
}
.all-in-one-menu-item-effects {
@apply cursor-pointer hover:bg-primary-dark hover:rounded;
}

View File

@@ -0,0 +1,19 @@
import Menu, { HorizontalDirection, VerticalDirection } from './Menu';
import DividerItem from './DividerItem';
import HeaderItem from './HeaderItem';
import IconMenu from './IconMenu';
import Item from './Item';
import ItemPanel from './ItemPanel';
import SubMenu from './SubMenu';
export {
Menu,
DividerItem,
HeaderItem,
IconMenu,
Item,
ItemPanel,
SubMenu,
HorizontalDirection,
VerticalDirection,
};