feat(toolbar): new Toolbar to enable reactive state synchronization (#3983)

This commit is contained in:
Alireza authored and GitHub committed 2024-03-27 16:01:32 -04:00
1 parent 79d5c36bda
commit 566b25a544
155 files changed
+6311 -6577

No files matched your search

@@ -3,6 +3,10 @@ import classnames from 'classnames';
import { PanelSection, Icon, Tooltip } from '../../components';
import ToolSettings from './ToolSettings';
/**
* Use Toolbox component instead of this although it doesn't have "Advanced" in its name
* it is better to use it instead of this one
*/
const AdvancedToolbox = ({ title, items }) => {
const [activeItemName, setActiveItemName] = useState(null);
@@ -1,10 +1,11 @@
import React from 'react';
import { ButtonGroup, InputRange, ButtonEnums } from '../../components';
import { ButtonGroup, InputDoubleRange, InputRange } from '../../components';
const SETTING_TYPES = {
RANGE: 'range',
RADIO: 'radio',
CUSTOM: 'custom',
DOUBLE_RANGE: 'double-range',
};
function ToolSettings({ options }) {
@@ -12,73 +13,111 @@ function ToolSettings({ options }) {
return null;
}
const getButtons = option => {
const buttons = [];
option.values?.map(({ label, value: optionValue }, index) => {
buttons.push({
children: label,
onClick: () => option.onChange(optionValue),
key: `button-${option.id}-${index}`, // A unique key
});
});
return buttons;
};
return (
<div className="space-y-2 py-2 text-white">
{options?.map(option => {
if (option.type === SETTING_TYPES.RANGE) {
return (
<div
className="flex items-center"
key={option.id}
>
<div className="w-1/3 text-[13px]">{option.name}</div>
<div className="w-2/3">
<InputRange
minValue={option.min}
maxValue={option.max}
step={option.step}
value={option.value}
onChange={e => option.onChange(e)}
allowNumberEdit={true}
showAdjustmentArrows={false}
inputClassName="ml-1 w-4/5 cursor-pointer"
/>
</div>
</div>
);
if (option.condition && option.condition?.({ options }) === false) {
return null;
}
if (option.type === SETTING_TYPES.RADIO) {
return (
<div
className="flex items-center justify-between text-[13px]"
key={option.id}
>
<span>{option.name}</span>
<div className="max-w-1/2">
<ButtonGroup
buttons={getButtons(option)}
defaultActiveIndex={option.defaultActiveIndex}
size={ButtonEnums.size.small}
/>
</div>
</div>
);
}
if (option.type === SETTING_TYPES.CUSTOM) {
return (
<div key={option.id}>
{typeof option.children === 'function' ? option.children() : option.children}
</div>
);
switch (option.type) {
case SETTING_TYPES.RANGE:
return renderRangeSetting(option);
case SETTING_TYPES.RADIO:
return renderRadioSetting(option);
case SETTING_TYPES.DOUBLE_RANGE:
return renderDoubleRangeSetting(option);
case SETTING_TYPES.CUSTOM:
return renderCustomSetting(option);
default:
return null;
}
})}
</div>
);
}
const renderRangeSetting = option => {
return (
<div
className="flex items-center"
key={option.id}
>
<div className="w-1/3 text-[13px]">{option.name}</div>
<div className="w-2/3">
<InputRange
minValue={option.min}
maxValue={option.max}
step={option.step}
value={option.value}
onChange={value => option.commands?.(value)}
allowNumberEdit={true}
showAdjustmentArrows={false}
inputClassName="ml-1 w-4/5 cursor-pointer"
/>
</div>
</div>
);
};
const renderRadioSetting = option => {
const renderButtons = option => {
return option.values?.map(({ label, value: optionValue }, index) => (
<button
onClick={() => {
option.commands?.(optionValue);
}}
key={`button-${option.id}-${index}`}
>
{label}
</button>
));
};
return (
<div
className="flex items-center justify-between text-[13px]"
key={option.id}
>
<span>{option.name}</span>
<div className="max-w-1/2">
<ButtonGroup
activeIndex={option.values.findIndex(({ value }) => value === option.value) || 0}
>
{renderButtons(option)}
</ButtonGroup>
</div>
</div>
);
};
const renderDoubleRangeSetting = option => {
return (
<div
className="flex w-full items-center"
key={option.id}
>
<InputDoubleRange
values={option.values}
onChange={option.commands}
minValue={option.min}
maxValue={option.max}
step={option.step}
showLabel={true}
allowNumberEdit={true}
showAdjustmentArrows={false}
containerClassName="w-full"
/>
</div>
);
};
const renderCustomSetting = option => {
return (
<div key={option.id}>
{typeof option.children === 'function' ? option.children() : option.children}
</div>
);
};
export default ToolSettings;
@@ -1,2 +1,4 @@
import AdvancedToolbox from './AdvancedToolbox';
import ToolSettings from './ToolSettings';
export default AdvancedToolbox;
export { ToolSettings };
+6 -1
View File
@@ -67,9 +67,12 @@ const Button = ({
name,
className,
onClick,
dataCY,
startIconTooltip = null,
endIconTooltip = null,
}) => {
dataCY = dataCY || `${name}-btn`;
const startIcon = startIconProp && (
<>
{React.cloneElement(startIconProp, {
@@ -109,7 +112,7 @@ const Button = ({
disabled={disabled}
ref={buttonElement}
onClick={handleOnClick}
data-cy={`${name}-btn`}
data-cy={dataCY}
>
{startIconTooltip ? <Tooltip content={startIconTooltip}>{startIcon}</Tooltip> : startIcon}
{children}
@@ -148,6 +151,8 @@ Button.propTypes = {
startIconTooltip: PropTypes.node,
/** Tooltip for the end icon */
endIconTooltip: PropTypes.node,
/** Data attribute for testing */
dataCY: PropTypes.string,
};
export default Button;
@@ -1,21 +1,25 @@
import React, { useState } from 'react';
import React, { useState, useEffect, cloneElement, Children } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { ButtonEnums } from '../../components';
const ButtonGroup = ({
buttons,
onActiveIndexChange,
children,
className,
orientation = ButtonEnums.orientation.horizontal,
defaultActiveIndex = 0,
activeIndex: defaultActiveIndex = 0,
onActiveIndexChange,
disabled = false,
}) => {
const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
const handleButtonClick = (e, index) => {
useEffect(() => {
setActiveIndex(defaultActiveIndex);
}, [defaultActiveIndex]);
const handleButtonClick = index => {
setActiveIndex(index);
onActiveIndexChange && onActiveIndexChange(index);
buttons[index].onClick && buttons[index].onClick(e);
};
const orientationClasses = {
@@ -29,35 +33,40 @@ const ButtonGroup = ({
<div
className={classnames(
wrapperClasses,
'border-secondary-light rounded-[5px] border bg-black text-[13px] '
'border-secondary-light rounded-[5px] border bg-black text-[13px]'
)}
>
{buttons.map((buttonProps, index) => {
const isActive = index === activeIndex;
return (
<button
{...buttonProps}
key={index}
className={classnames(
{Children.map(children, (child, index) => {
if (React.isValidElement(child)) {
return cloneElement(child, {
key: index,
className: classnames(
'rounded-[4px] px-2 py-1',
isActive ? 'bg-customblue-40 text-white' : 'text-primary-active bg-black'
)}
onClick={e => handleButtonClick(e, index)}
/>
);
index === activeIndex
? 'bg-customblue-40 text-white'
: 'text-primary-active bg-black',
child.props.className,
disabled ? 'ohif-disabled' : ''
),
onClick: e => {
child.props.onClick && child.props.onClick(e);
handleButtonClick(index);
},
});
}
return child;
})}
</div>
);
};
ButtonGroup.propTypes = {
buttons: PropTypes.arrayOf(PropTypes.object).isRequired,
children: PropTypes.node.isRequired,
orientation: PropTypes.oneOf(Object.values(ButtonEnums.orientation)),
type: PropTypes.oneOf(Object.values(ButtonEnums.type)),
size: PropTypes.oneOf(Object.values(ButtonEnums.size)),
defaultActiveIndex: PropTypes.number,
activeIndex: PropTypes.number,
onActiveIndexChange: PropTypes.func,
className: PropTypes.string,
disabled: PropTypes.bool,
};
export default ButtonGroup;
@@ -16,10 +16,6 @@ export const argTypes = {
component={ButtonGroup}
/>
export const buttonGroupTemplate = args => {
return <ButtonGroup {...args} />;
};
<Heading
title="ButtonGroup"
componentRelativePath="ButtonGroup/ButtonGroup.tsx"
@@ -35,31 +31,16 @@ export const buttonGroupTemplate = args => {
ButtonGroup is a container for a group of buttons.
<Canvas>
<Story
name="Overview"
args={{
type: ButtonEnums.type.primary,
size: ButtonEnums.size.medium,
orientation: ButtonEnums.orientation.horizontal,
buttons: [
{
children: 'Button 1',
onClick: () => console.log('Button 1 clicked'),
},
{
children: 'Button 2',
onClick: () => console.log('Button 2 clicked'),
},
{
children: 'Button 3',
onClick: () => console.log('Button 3 clicked'),
},
],
activeIndex: 0,
onActiveIndexChange: index => console.log(index),
}}
>
{buttonGroupTemplate.bind({})}
<Story name="Overview">
<ButtonGroup
orientation={ButtonEnums.orientation.horizontal}
activeIndex={0}
onActiveIndexChange={index => console.log(index)}
>
<button onClick={() => console.log('Button 1111 clicked')}>Button 1</button>
<button onClick={() => console.log('Button 222 clicked')}>Button 2</button>
<button onClick={() => console.log('Button 33 clicked')}>Button 3</button>
</ButtonGroup>
</Story>
</Canvas>
+8 -7
View File
@@ -30,13 +30,11 @@ function Header({
return (
<NavBar
className="justify-between border-b-4 border-black"
isSticky={isSticky}
{...props}
>
<div className="flex flex-1 justify-between">
<div className="flex items-center">
{/* // TODO: Should preserve filter/sort
// Either injected service? Or context (like react router's `useLocation`?) */}
<div className="relative h-[48px] items-center ">
<div className="absolute left-0 top-1/2 -translate-y-1/2 ">
<div
className={classNames(
'mr-3 inline-flex items-center',
@@ -56,8 +54,11 @@ function Header({
</div>
</div>
</div>
<div className="flex items-center">{children}</div>
<div className="flex items-center">
{/* <div className="absolute top-1/2 left-[211px] h-8 w-20 -translate-y-1/2">{future left component}</div> */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<div className="flex items-center justify-center space-x-2">{children}</div>
</div>
<div className="absolute right-0 top-1/2 -translate-y-1/2 ">
<Dropdown
id="options"
showDropdownIcon={false}
+2 -2
View File
@@ -94,7 +94,7 @@ import toolMove from './../../assets/icons/tool-move.svg';
import toolWindow from './../../assets/icons/tool-window-level.svg';
import toolAnnotate from './../../assets/icons/tool-annotate.svg';
import toolBidirectional from './../../assets/icons/tool-bidirectional.svg';
import toolElipse from './../../assets/icons/tool-elipse.svg';
import toolElipse from './../../assets/icons/tool-ellipse.svg';
import toolCircle from './../../assets/icons/tool-circle.svg';
import toolLength from './../../assets/icons/tool-length.svg';
import toolStackScroll from './../../assets/icons/tool-stack-scroll.svg';
@@ -235,7 +235,7 @@ const ICONS = {
'tool-window-level': toolWindow,
'tool-annotate': toolAnnotate,
'tool-bidirectional': toolBidirectional,
'tool-elipse': toolElipse,
'tool-ellipse': toolElipse,
'tool-circle': toolCircle,
'tool-length': toolLength,
'tool-stack-scroll': toolStackScroll,
@@ -14,7 +14,7 @@ const roundedClasses = {
};
const disabledClasses = {
true: 'cursor-not-allowed',
true: 'ohif-disabled',
false: '',
};
@@ -43,7 +43,7 @@ const variantClasses = {
'border border-primary-main text-white hover:bg-primary-main focus:bg-primary-main hover:border-black focus:border-black',
},
contained: {
default: 'text-black hover:opacity-80 active:opacity-100 focus:opacity-80',
default: 'text-common-bright hover:opacity-80 active:opacity-100 focus:opacity-80',
primary: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
secondary: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
white: 'text-black hover:opacity-80 active:opacity-100 focus:opacity-80',
@@ -51,30 +51,6 @@ const variantClasses = {
},
};
const backgroundClasses = {
text: {
default: '',
primary: '',
secondary: '',
white: '',
black: '',
},
outlined: {
default: 'bg-transparent',
primary: 'bg-transparent',
secondary: 'bg-transparent',
white: 'bg-transparent',
black: 'bg-black',
},
contained: {
default: 'bg-primary-light',
primary: 'bg-primary-main',
secondary: 'bg-secondary-light',
white: 'bg-white',
black: 'bg-black',
},
};
const sizeClasses = {
small: 'py-2 px-2 text-base',
medium: 'py-3 px-3 text-lg',
@@ -87,7 +63,7 @@ const iconSizeClasses = {
small: 'w-4 h-4',
medium: 'w-5 h-5',
large: 'w-6 h-6',
toolbar: 'w-5 h-5',
toolbar: 'w-[24px] h-[24px]',
};
const fullWidthClasses = {
@@ -106,9 +82,7 @@ const IconButton = ({
fullWidth,
onClick,
className,
name,
id,
bgColor,
...rest
}) => {
const buttonElement = useRef(null);
@@ -118,7 +92,7 @@ const IconButton = ({
onClick(e);
};
const bgColorToUse = bgColor ? bgColor : backgroundClasses[variant][color];
const padding = size === 'toolbar' ? '8px' : size === 'toolbox' ? '4px' : null;
return (
<button
@@ -129,11 +103,10 @@ const IconButton = ({
sizeClasses[size],
fullWidthClasses[fullWidth],
disabledClasses[disabled],
bgColorToUse,
className
)}
style={{
padding: size === 'toolbar' ? '10px' : null,
padding,
}}
ref={buttonElement}
onClick={handleOnClick}
@@ -161,7 +134,7 @@ IconButton.defaultProps = {
IconButton.propTypes = {
children: PropTypes.node.isRequired,
size: PropTypes.oneOf(['small', 'medium', 'large', 'initial', 'toolbar']),
size: PropTypes.oneOf(['small', 'medium', 'large', 'initial', 'toolbar', 'toolbox']),
rounded: PropTypes.oneOf(['none', 'small', 'medium', 'large', 'full']),
variant: PropTypes.oneOf(['text', 'outlined', 'contained']),
color: PropTypes.oneOf(['default', 'primary', 'secondary', 'white', 'black', 'inherit']),
@@ -0,0 +1,170 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import OutsideClickHandler from 'react-outside-click-handler';
import { useTranslation } from 'react-i18next';
import Icon from '../Icon';
import Tooltip from '../Tooltip';
import ListMenu from '../ListMenu';
const baseClasses = {
Button: 'flex items-center rounded-md border-transparent cursor-pointer group/button',
Primary:
// By default border on left, top and bottom for hover effect and only rounded on left side.
// Extra padding on right to compensate for no right border.
'h-full border-l-2 border-t-2 border-b-2 rounded-tl-md rounded-bl-md group/primary !pl-2 !py-2',
Secondary:
'h-full flex items-center justify-center rounded-tr-md rounded-br-md w-4 border-2 border-transparent group/secondary',
SecondaryIcon: 'w-4 h-full stroke-1',
Separator: 'border-l py-2.5',
Content: 'absolute z-10 top-0 mt-12',
};
const classes = {
Button: ({ isExpanded, primary }) =>
classNames(
baseClasses.Button,
!isExpanded && !primary.isActive && 'hover:!bg-primary-dark hover:border-primary-dark'
),
Interface: 'h-full flex flex-row items-center',
Primary: ({ isActive, isExpanded }) =>
classNames(
baseClasses.Primary,
isActive
? isExpanded
? 'border-primary-dark !bg-primary-dark hover:border-primary-dark !text-primary-light'
: 'border-primary-light bg-primary-light border-2 rounded-md !p-2'
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light
${
isExpanded
? 'border-primary-dark bg-primary-dark !text-primary-light'
: 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'
}
`
),
Secondary: ({ isExpanded, primary }) =>
classNames(
baseClasses.Secondary,
isExpanded
? 'bg-primary-light !rounded-tr-md !rounded-br-md'
: primary.isActive
? 'bg-secondary-dark'
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
),
SecondaryIcon: ({ isExpanded }) =>
classNames(
baseClasses.SecondaryIcon,
isExpanded
? 'text-primary-dark'
: 'text-primary-active group-hover/secondary:text-primary-light'
),
Separator: ({ primary, isExpanded, isHovering }) =>
classNames(
baseClasses.Separator,
isHovering || isExpanded || primary.isActive ? 'border-transparent' : 'border-primary-active'
),
Content: ({ isExpanded }) => classNames(baseClasses.Content, isExpanded ? 'block' : 'hidden'),
};
const SplitButton = ({
groupId,
primary,
secondary,
items,
renderer,
onInteraction,
Component,
isActive,
}) => {
const { t } = useTranslation('Buttons');
const [state, setState] = useState({ isHovering: false, isExpanded: false });
const toggleExpanded = () => setState({ ...state, isExpanded: !state.isExpanded });
const setHover = hovering => setState({ ...state, isHovering: hovering });
const collapse = () => setState({ ...state, isExpanded: false });
const renderPrimaryButton = () => (
<Component
key={primary.id}
{...primary}
isActive={isActive}
onInteraction={onInteraction}
rounded="none"
className={classes.Primary({ isActive, isExpanded: state.isExpanded })}
data-tool={primary.id}
data-cy={`${groupId}-split-button-primary`}
/>
);
return (
<OutsideClickHandler
onOutsideClick={collapse}
disabled={!state.isExpanded}
>
<div
id="SplitButton"
className="relative"
>
<div
className={classes.Button({ ...state, primary: { isActive } })}
style={{ height: '40px' }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<div className={classes.Interface}>
<div onClick={collapse}>{renderPrimaryButton()}</div>
<div className={classes.Separator({ ...state, primary: { isActive } })}></div>
<div
className={classes.Secondary({ ...state, primary: { isActive } })}
onClick={toggleExpanded}
data-cy={`${groupId}-split-button-secondary`}
>
<Tooltip
isDisabled={state.isExpanded || !secondary.tooltip}
content={secondary.tooltip}
className="h-full"
>
<Icon
name={secondary.icon}
className={classes.SecondaryIcon({ ...state })}
/>
</Tooltip>
</div>
</div>
</div>
<div
className={classes.Content({ ...state })}
data-cy={`${groupId}-list-menu`}
>
<ListMenu
items={items}
onClick={collapse}
renderer={args => renderer({ ...args, t })}
/>
</div>
</div>
</OutsideClickHandler>
);
};
SplitButton.propTypes = {
isToggle: PropTypes.bool,
groupId: PropTypes.string.isRequired,
primary: PropTypes.object.isRequired,
secondary: PropTypes.object.isRequired,
items: PropTypes.array.isRequired,
renderer: PropTypes.func,
isActive: PropTypes.bool,
onInteraction: PropTypes.func.isRequired,
Component: PropTypes.elementType,
};
SplitButton.defaultProps = {
isToggle: false,
renderer: null,
isActive: false,
Component: null,
};
export default SplitButton;
@@ -0,0 +1,2 @@
import LegacySplitButton from './LegacySplitButton';
export default LegacySplitButton;
@@ -2,24 +2,25 @@ import React, { useState } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
const flex = 'flex flex-row justify-between items-center';
const theme = 'bg-indigo-dark text-white';
const ListMenu = ({ items = [], renderer, onClick }) => {
const [selectedIndex, setSelectedIndex] = useState(null);
const ListItem = ({ item, index, isSelected }) => {
const flex = 'flex flex-row justify-between items-center';
const theme = 'bg-indigo-dark';
const onClickHandler = () => {
setSelectedIndex(index);
onClick({ item, selectedIndex: index });
if (item.onClick) {
item.onClick({ ...item, index, isSelected });
}
item.onClick?.({ ...item, index, isSelected });
};
return (
<div
className={classnames(flex, theme, 'cursor-pointer')}
className={classnames(flex, theme, {
'cursor-pointer': !item.disabled,
'ohif-disabled': item.disabled,
})}
onClick={onClickHandler}
data-cy={item.id}
>
+1 -2
View File
@@ -9,12 +9,11 @@ const NavBar = ({ className, children, isSticky }) => {
return (
<div
className={classnames(
'bg-secondary-dark z-20 flex flex-row items-center border-b-4 border-black px-1',
'bg-secondary-dark z-20 border-black px-1',
isSticky && stickyClasses,
!isSticky && notStickyClasses,
className
)}
style={{ paddingTop: '4px', paddingBottom: '4px', minHeight: '52px' }}
>
{children}
</div>
@@ -37,7 +37,7 @@ const SegmentItem = ({
>
<div
className={classnames('bg-primary-dark group/number grid w-[32px] place-items-center', {
'bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
'!bg-primary-light border-primary-light rounded-l-[4px] border text-black': isActive,
'border-primary-dark border': !isActive,
})}
onMouseEnter={() => setIsNumberBoxHovering(true)}
@@ -98,10 +98,11 @@ const getTabClassNames = (
numColumns: number,
numTabs: number,
tabIndex: number,
isActiveTab: boolean
isActiveTab: boolean,
isTabDisabled: boolean
) =>
classnames('h-[28px] mb-[2px] cursor-pointer text-white bg-black', {
'hover:text-primary-active': !isActiveTab,
'hover:text-primary-active': !isActiveTab && !isTabDisabled,
'rounded-l': tabIndex % numColumns === 0,
'rounded-r': (tabIndex + 1) % numColumns === 0 || tabIndex === numTabs - 1,
});
@@ -137,6 +138,15 @@ const createStyleMap = (
};
};
const getToolTipContent = (label: string, disabled: boolean) => {
return (
<>
<div>{label}</div>
{disabled && <div className="text-white">{'Not available based on current context'}</div>}
</>
);
};
const createBaseStyle = (expandedWidth: number) => {
return {
maxWidth: `${expandedWidth}px`,
@@ -218,7 +228,7 @@ const SidePanel = ({
<Tooltip
position={side === 'left' ? 'right' : 'left'}
key={index}
content={`${childComponent.label}`}
content={getToolTipContent(childComponent.label, childComponent.disabled)}
className={classnames(
'flex items-center',
side === 'left' ? 'justify-end ' : 'justify-start '
@@ -229,12 +239,15 @@ const SidePanel = ({
data-cy={`${childComponent.name}-btn`}
className="text-primary-active hover:cursor-pointer"
onClick={() => {
updateActiveTabIndex(index);
return childComponent.disabled ? null : updateActiveTabIndex(index);
}}
>
<Icon
name={childComponent.iconName}
className="text-primary-active"
className={classnames({
'text-primary-active': true,
'ohif-disabled': childComponent.disabled,
})}
style={{
width: '22px',
height: '22px',
@@ -279,6 +292,7 @@ const SidePanel = ({
style={getGridStyle(side, tabs.length, gridWidth, expandedWidth)}
>
{tabs.map((tab, tabIndex) => {
const { disabled } = tab;
return (
<React.Fragment key={tabIndex}>
{tabIndex % numCols !== 0 && (
@@ -294,22 +308,26 @@ const SidePanel = ({
<Tooltip
position={'bottom'}
key={tabIndex}
content={`${tab.label}`}
content={getToolTipContent(tab.label, disabled)}
>
<div
className={getTabClassNames(
numCols,
tabs.length,
tabIndex,
tabIndex === activeTabIndex
tabIndex === activeTabIndex,
disabled
)}
style={getTabStyle(tabs.length)}
onClick={() => updateActiveTabIndex(tabIndex)}
onClick={() => {
return disabled ? null : updateActiveTabIndex(tabIndex);
}}
data-cy={`${tab.name}-btn`}
>
<div className={getTabIconClassNames(tabs.length, tabIndex === activeTabIndex)}>
<Icon
name={tab.iconName}
className={`${tab.disabled && 'ohif-disabled'}`}
style={{
width: '22px',
height: '22px',
@@ -11,42 +11,29 @@ import ListMenu from '../ListMenu';
const baseClasses = {
Button: 'flex items-center rounded-md border-transparent cursor-pointer group/button',
Primary:
// By default border on left, top and bottom for hover effect and only rounded on left side.
// Extra padding on right to compensate for no right border.
'h-full border-l-2 border-t-2 border-b-2 rounded-tl-md rounded-bl-md group/primary !pl-2 !py-2',
Secondary:
'h-full flex items-center justify-center rounded-tr-md rounded-br-md w-4 border-2 border-transparent group/secondary',
SecondaryIcon: 'w-4 h-full stroke-1',
Separator: 'border-l py-2.5',
Separator: 'border-l py-3 ml-0.5',
Content: 'absolute z-10 top-0 mt-12',
};
const classes = {
Button: ({ isExpanded, primary }) =>
Button: ({ isExpanded }) =>
classNames(
baseClasses.Button,
!isExpanded && !primary.isActive && 'hover:!bg-primary-dark hover:border-primary-dark'
!isExpanded && 'hover:!bg-primary-dark hover:border-primary-dark'
),
Interface: 'h-full flex flex-row items-center',
Primary: ({ primary, isExpanded }) =>
Primary: ({ isExpanded, isActive }) =>
classNames(
baseClasses.Primary,
primary.isActive
isActive
? isExpanded
? 'border-primary-dark !bg-primary-dark hover:border-primary-dark !text-primary-light'
: `${
primary.isToggle
? 'border-secondary-dark bg-secondary-light'
: 'border-primary-light bg-primary-light'
}
border-2 rounded-md !p-2` // Full, rounded border with less right padding when active.
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light
${
isExpanded
? 'border-primary-dark bg-primary-dark !text-primary-light'
: 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'
}
`
: 'border-primary-light bg-primary-light border-2 rounded-md !p-2'
: `focus:!text-black focus:!rounded-md focus:!border-primary-light focus:!bg-primary-light ${isExpanded ? 'border-primary-dark bg-primary-dark !text-primary-light' : 'border-secondary-dark bg-secondary-dark group-hover/button:border-primary-dark group-hover/button:text-primary-light hover:!bg-primary-dark hover:border-primary-dark focus:!text-black'}`
),
Secondary: ({ isExpanded, primary }) =>
classNames(
@@ -54,13 +41,15 @@ const classes = {
isExpanded
? 'bg-primary-light !rounded-tr-md !rounded-br-md'
: primary.isActive
? 'bg-secondary-dark'
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
? 'bg-secondary-dark'
: 'hover:bg-primary-dark bg-secondary-dark group-hover/button:border-primary-dark'
),
SecondaryIcon: ({ isExpanded }) =>
classNames(
baseClasses.SecondaryIcon,
isExpanded ? 'text-primary-dark' : 'text-[#348cfd] group-hover/secondary:text-primary-light'
isExpanded
? 'text-primary-dark'
: 'text-primary-active group-hover/secondary:text-primary-light'
),
Separator: ({ primary, isExpanded, isHovering }) =>
classNames(
@@ -70,14 +59,40 @@ const classes = {
Content: ({ isExpanded }) => classNames(baseClasses.Content, isExpanded ? 'block' : 'hidden'),
};
const DefaultListItemRenderer = props => {
const { t, icon, label, className, isActive } = props;
return (
<div
className={classNames(
'flex h-8 w-full flex-row items-center p-3',
'whitespace-pre text-base',
className,
`${isActive ? 'hover:opacity-80' : 'hover:bg-primary-dark '}`
)}
>
{icon && (
<span className="mr-4">
<Icon
name={icon}
className="h-5 w-5"
/>
</span>
)}
<span className="mr-5">{t?.(label)}</span>
</div>
);
};
/**
* This is a more generic version of SplitButton without the isActive
* and other interaction props
*/
const SplitButton = ({
isToggle,
groupId,
primary,
secondary,
items,
renderer,
isActive,
onInteraction,
Component,
}) => {
@@ -88,19 +103,14 @@ const SplitButton = ({
const setHover = hovering => setState({ ...state, isHovering: hovering });
const collapse = () => setState({ ...state, isExpanded: false });
const renderPrimaryButton = () => (
<Component
key={primary.id}
{...primary}
isActive={isActive}
onInteraction={onInteraction}
rounded="none"
className={classes.Primary({ ...state, primary: { isActive, isToggle } })}
data-tool={primary.id}
data-cy={`${groupId}-split-button-primary`}
/>
const listItemRenderer = renderer || DefaultListItemRenderer;
const primaryClassNames = classNames(
classes.Primary({
isExpanded: state.isExpanded,
isActive: primary.isActive,
}),
primary.className
);
return (
<OutsideClickHandler
onOutsideClick={collapse}
@@ -111,16 +121,26 @@ const SplitButton = ({
className="relative"
>
<div
className={classes.Button({ ...state, primary: { isActive } })}
className={classes.Button({ ...state })}
style={{ height: '40px' }}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<div className={classes.Interface}>
<div onClick={collapse}>{renderPrimaryButton()}</div>
<div className={classes.Separator({ ...state, primary: { isActive } })}></div>
<div onClick={collapse}>
<Component
key={primary.id}
{...primary}
onInteraction={onInteraction}
rounded="none"
className={primaryClassNames}
data-tool={primary.id}
data-cy={`${groupId}-split-button-primary`}
/>
</div>
<div className={classes.Separator({ ...state, primary })}></div>
<div
className={classes.Secondary({ ...state, primary: { isActive } })}
className={classes.Secondary({ ...state, primary })}
onClick={toggleExpanded}
data-cy={`${groupId}-split-button-secondary`}
>
@@ -131,7 +151,7 @@ const SplitButton = ({
>
<Icon
name={secondary.icon}
className={classes.SecondaryIcon({ ...state, primary: { isActive } })}
className={classes.SecondaryIcon({ ...state })}
/>
</Tooltip>
</div>
@@ -144,7 +164,7 @@ const SplitButton = ({
<ListMenu
items={items}
onClick={collapse}
renderer={args => renderer({ ...args, t })}
renderer={args => listItemRenderer({ ...args, t })}
/>
</div>
</div>
@@ -162,6 +182,7 @@ SplitButton.propTypes = {
isActive: PropTypes.bool,
onInteraction: PropTypes.func.isRequired,
Component: PropTypes.elementType,
interactionType: PropTypes.oneOf(['action', 'tool', 'toggle']),
};
SplitButton.defaultProps = {
@@ -2,6 +2,7 @@ import SplitButton from '../SplitButton';
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
import ToolbarButton from '../../ToolbarButton';
export const argTypes = {
component: SplitButton,
@@ -30,7 +31,7 @@ export const SplitButtonTemplate = args => (
## Overview
SplitButton is a component that renders the SplitButtons.
SplitButton is a component that renders the SplitButtons
<Canvas>
<Story
@@ -63,6 +64,7 @@ SplitButton is a component that renders the SplitButtons.
tooltip: 'Bidirectional',
},
],
Component: ToolbarButton,
}}
>
{SplitButtonTemplate.bind({})}
@@ -64,7 +64,7 @@ const Thumbnail = ({
<img
src={imageSrc}
alt={imageAltText}
className="min-h-32 object-none"
className="min-h-32 object-contain"
crossOrigin="anonymous"
/>
) : (
@@ -8,7 +8,9 @@ import Tooltip from '../Tooltip';
import { StringNumber } from '../../types';
import { useTranslation } from 'react-i18next';
function ThumbnailTracked ({
// Todo: This class to me feels like it belongs in an extension, not in platform/ui
// because it is dealing with mode specific components/information
function ThumbnailTracked({
displaySetInstanceUID,
className,
imageSrc,
@@ -87,12 +89,12 @@ function ThumbnailTracked ({
</div>
<div className="flex flex-1 flex-col">
<span>
<span className="text-white">{isTracked ? t('Series is tracked') : t('Series is untracked')}</span>
<span className="text-white">
{isTracked ? t('Series is tracked') : t('Series is untracked')}
</span>
</span>
{!!viewportIdentificator.length && (
<span>
{`${t('Viewport')}: ${viewportIdentificatorLabel}`}
</span>
<span>{`${t('Viewport')}: ${viewportIdentificatorLabel}`}</span>
)}
</div>
</div>
@@ -136,7 +138,7 @@ function ThumbnailTracked ({
/>
</div>
);
};
}
ThumbnailTracked.propTypes = {
/**
@@ -1,13 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import IconButton from '../IconButton';
import Icon from '../Icon';
import Tooltip from '../Tooltip';
const ToolbarButton = ({
type = 'tool',
id,
icon,
label,
@@ -15,47 +13,37 @@ const ToolbarButton = ({
onInteraction,
dropdownContent,
//
isActive,
className,
size,
toolTipClassName,
...rest
//
}) => {
const classes = {
tool: isActive
? 'text-black'
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
toggle: isActive
? '!text-[#348CFD]'
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
action: isActive
? 'text-black'
: 'text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
const bgClasses = {
toggle: isActive && 'bg-transparent',
};
const activeClass = isActive ? 'active' : '';
const shouldShowDropdown = !!isActive && !!dropdownContent;
const shouldShowDropdown = !!dropdownContent;
const iconEl = icon ? <Icon name={icon} /> : <div>{label || 'Missing icon and label'}</div>;
const sizeToUse = size ?? 'toolbar';
const toolTipClassNameToUse =
toolTipClassName !== undefined
? toolTipClassName
: sizeToUse === 'toolbar'
? 'w-[40px] h-[40px]'
: 'w-[32px] h-[32px]';
return (
<div key={id}>
<Tooltip
isSticky={shouldShowDropdown}
content={shouldShowDropdown ? dropdownContent : label}
tight={shouldShowDropdown}
className={toolTipClassNameToUse}
>
<IconButton
variant={isActive ? 'contained' : 'text'}
bgColor={bgClasses[type]}
size="toolbar"
className={classnames(activeClass, classes[type], className)}
size={sizeToUse}
className={className}
onClick={() => {
onInteraction({
itemId: id,
interactionType: type,
commands,
});
}}
@@ -73,27 +61,20 @@ const ToolbarButton = ({
ToolbarButton.defaultProps = {
dropdownContent: null,
isActive: false,
type: 'action',
};
ToolbarButton.propTypes = {
/* Influences background/hover styling */
type: PropTypes.oneOf(['action', 'toggle', 'tool']),
id: PropTypes.string.isRequired,
isActive: PropTypes.bool,
className: PropTypes.string,
commands: PropTypes.arrayOf(
PropTypes.shape({
commandName: PropTypes.string.isRequired,
commandOptions: PropTypes.object,
})
),
onInteraction: PropTypes.func.isRequired,
commands: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.string]),
onInteraction: PropTypes.func,
icon: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
/** Tooltip content can be replaced for a customized content by passing a node to this value. */
dropdownContent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
size: PropTypes.string,
toolTipClassName: PropTypes.string,
};
export default ToolbarButton;
@@ -0,0 +1,109 @@
import React, { useEffect, useRef } from 'react';
import { useToolbar } from '@ohif/core';
import { ToolboxUI } from './';
import { useToolbox } from '../../contextProviders';
/**
* A toolbox is a collection of buttons and commands that they invoke, used to provide
* custom control panels to users. This component is a generic UI component that
* interacts with services and commands in a generic fashion. While it might
* seem unconventional to import it from the UI and integrate it into the JSX,
* it belongs in the UI components as there isn't anything in this component that
* couldn't be used for a completely different type of app. It plays a crucial
* role in enhancing the app with a toolbox by providing a way to integrate
* and display various tools and their corresponding options
*/
function Toolbox({ servicesManager, buttonSectionId, commandsManager, title, ...props }) {
const { state: toolboxState, api } = useToolbox(buttonSectionId);
const { onInteraction, toolbarButtons } = useToolbar({
servicesManager,
buttonSection: buttonSectionId,
});
const prevButtonIdsRef = useRef();
useEffect(() => {
const currentButtonIdsStr = JSON.stringify(
toolbarButtons.map(button => {
const { id, componentProps } = button;
if (componentProps.items?.length) {
return componentProps.items.map(item => `${item.id}-${item.disabled}`);
}
return `${id}-${componentProps.disabled}`;
})
);
if (prevButtonIdsRef.current === currentButtonIdsStr) {
return;
}
prevButtonIdsRef.current = currentButtonIdsStr;
const initializeOptionsWithEnhancements = toolbarButtons.reduce(
(accumulator, toolbarButton) => {
const { id: buttonId, componentProps } = toolbarButton;
const createEnhancedOptions = (options, parentId) =>
options.map(option => {
return {
...option,
value:
toolboxState.toolOptions?.[parentId]?.find(prop => prop.id === option.id)?.value ??
option.value,
commands: value => {
api.handleToolOptionChange(parentId, option.id, value);
const { isArray } = Array;
const cmds = isArray(option.commands) ? option.commands : [option.commands];
cmds.forEach(command => {
const isString = typeof command === 'string';
const isObject = typeof command === 'object';
if (isString) {
commandsManager.run(command, { value });
} else if (isObject) {
commandsManager.run({
...command,
commandOptions: { ...command.commandOptions, ...option, value },
});
}
});
},
};
});
if (componentProps.items?.length) {
componentProps.items.forEach(item => {
accumulator[item.id] = createEnhancedOptions(item.options, item.id);
});
} else if (componentProps.options?.length) {
accumulator[buttonId] = createEnhancedOptions(componentProps.options, buttonId);
}
return accumulator;
},
{}
);
api.initializeToolOptions(initializeOptionsWithEnhancements);
}, [toolbarButtons, api]);
const handleToolOptionChange = (toolName, optionName, newValue) => {
api.handleToolOptionChange(toolName, optionName, newValue);
};
return (
<ToolboxUI
{...props}
title={title}
toolbarButtons={toolbarButtons}
activeToolOptions={toolboxState.toolOptions?.[toolboxState.activeTool]}
handleToolSelect={id => api.handleToolSelect(id)}
handleToolOptionChange={handleToolOptionChange}
onInteraction={onInteraction}
/>
);
}
export default Toolbox;
@@ -0,0 +1,92 @@
import React from 'react';
import { PanelSection, ToolSettings, Tooltip } from '../../components';
import classnames from 'classnames';
const ItemsPerRow = 4;
/**
* Just refactoring from the toolbox component to make it more readable
*/
function ToolboxUI(props) {
const {
toolbarButtons,
handleToolSelect,
activeToolOptions,
numRows,
servicesManager,
title,
useCollapsedPanel = true,
} = props;
const render = () => {
return (
<>
<div className="flex flex-col bg-black">
<div className="bg-primary-dark mt-0.5 flex flex-wrap py-2">
{toolbarButtons.map((toolDef, index) => {
if (!toolDef) {
return null;
}
const { id, Component, componentProps } = toolDef;
const isLastRow = Math.floor(index / ItemsPerRow) + 1 === numRows;
const toolClasses = `ml-1 ${isLastRow ? '' : 'mb-2'}`;
const onInteraction = ({ itemId, id, commands }) => {
handleToolSelect(itemId || id);
props.onInteraction({
itemId,
commands,
});
};
return (
<div
key={id}
className={classnames({
[toolClasses]: true,
'flex flex-col items-center justify-center': true,
})}
>
{componentProps.disabled ? (
<Tooltip
position="bottom"
content={componentProps.label}
secondaryContent={'Not available on the current viewport'}
>
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
</Tooltip>
) : (
<Component
{...componentProps}
{...props}
id={id}
servicesManager={servicesManager}
onInteraction={onInteraction}
size="toolbox"
/>
)}
</div>
);
})}
</div>
</div>
<div className="bg-primary-dark h-auto px-2">
{activeToolOptions && <ToolSettings options={activeToolOptions} />}
</div>
</>
);
};
return useCollapsedPanel ? <PanelSection title={title}>{render()}</PanelSection> : render();
}
export { ToolboxUI };
@@ -0,0 +1,3 @@
import { ToolboxUI } from './ToolboxUI';
import Toolbox from './Toolbox';
export { ToolboxUI, Toolbox };
+15 -3
View File
@@ -30,7 +30,16 @@ const arrowPositionStyle = {
},
};
const Tooltip = ({ content, isSticky, position, className, tight, children, isDisabled }) => {
const Tooltip = ({
content,
secondaryContent = null,
isSticky,
position,
className,
tight,
children,
isDisabled,
}) => {
const [isActive, setIsActive] = useState(false);
const { t } = useTranslation('Buttons');
@@ -68,11 +77,14 @@ const Tooltip = ({ content, isSticky, position, className, tight, children, isDi
className={classnames(
'tooltip-box bg-primary-dark border-secondary-light w-max-content relative inset-x-auto top-full rounded border text-base text-white',
{
'py-1 px-4': !tight,
'py-[6px] px-[8px]': !tight,
}
)}
>
{typeof content === 'string' ? t(content) : content}
<div>{typeof content === 'string' ? t(content) : content}</div>
<div className="text-aqua-pale">
{typeof secondaryContent === 'string' ? t(secondaryContent) : secondaryContent}
</div>
<svg
className="text-primary-dark stroke-secondary-light absolute h-4"
style={arrowPositionStyle[position]}
@@ -10,10 +10,24 @@ const classes = {
bottomLeft: 'bottom-viewport left-viewport',
};
const ViewportOverlay = ({ topLeft, topRight, bottomRight, bottomLeft, color }) => {
export type ViewportOverlayProps = {
topLeft: React.ReactNode;
topRight: React.ReactNode;
bottomRight: React.ReactNode;
bottomLeft: React.ReactNode;
color?: string;
};
const ViewportOverlay = ({
topLeft,
topRight,
bottomRight,
bottomLeft,
color = 'text-primary-light',
}: ViewportOverlayProps) => {
const overlay = 'absolute pointer-events-none viewport-overlay';
return (
<div className={classnames(color ? color : 'text-primary-light')}>
<div className={color}>
<div
data-cy={'viewport-overlay-top-left'}
className={classnames(overlay, classes.topLeft)}
+6
View File
@@ -79,6 +79,9 @@ import PanelSection from './PanelSection';
import AdvancedToolbox from './AdvancedToolbox';
import InputDoubleRange from './InputDoubleRange';
import LegacyButtonGroup from './LegacyButtonGroup';
import LegacySplitButton from './LegacySplitButton';
import { ToolSettings } from './AdvancedToolbox';
import { Toolbox } from './Toolbox';
import InvestigationalUseDialog from './InvestigationalUseDialog';
import MeasurementItem from './MeasurementTable/MeasurementItem';
@@ -165,6 +168,9 @@ export {
ViewportPane,
ViewportOverlay,
WindowLevelMenuItem,
LegacySplitButton,
ToolSettings,
Toolbox,
InvestigationalUseDialog,
MeasurementItem,
};