-
+ >
);
}
diff --git a/extensions/tmtv/src/getPanelModule.tsx b/extensions/tmtv/src/getPanelModule.tsx
index 239233978..113170993 100644
--- a/extensions/tmtv/src/getPanelModule.tsx
+++ b/extensions/tmtv/src/getPanelModule.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import { PanelPetSUV, PanelROIThresholdExport } from './Panels';
-import { Toolbox } from '@ohif/ui';
+import { Toolbox as NewToolbox } from '@ohif/ui-next';
+import { Toolbox as OldToolbox } from '@ohif/ui';
+import { useAppConfig } from '@state';
// TODO:
// - No loading UI exists yet
@@ -8,16 +10,24 @@ import { Toolbox } from '@ohif/ui';
// - show errors in UI for thumbnails if promise fails
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
- const wrappedPanelPetSuv = () => {
+ const wrappedPanelPetSuv = ({ renderHeader, getCloseIcon, tab }) => {
return (
);
};
- const wrappedROIThresholdToolbox = () => {
+ const wrappedROIThresholdToolbox = ({ renderHeader, getCloseIcon, tab }: withAppTypes) => {
+ const [appConfig] = useAppConfig();
+
+ const Toolbox = appConfig.useExperimentalUI ? NewToolbox : OldToolbox;
+
return (
<>
>
);
diff --git a/modes/preclinical-4d/src/getWorkflowSettings.ts b/modes/preclinical-4d/src/getWorkflowSettings.ts
index efaab9b1f..a33571644 100644
--- a/modes/preclinical-4d/src/getWorkflowSettings.ts
+++ b/modes/preclinical-4d/src/getWorkflowSettings.ts
@@ -7,7 +7,7 @@ const dynamicVolume = {
};
const cornerstone = {
- segmentation: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation',
+ segmentation: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentationNoHeader',
activeViewportWindowLevel: '@ohif/extension-cornerstone.panelModule.activeViewportWindowLevel',
};
diff --git a/modes/tmtv/src/index.ts b/modes/tmtv/src/index.ts
index 1ef04ca73..37281486e 100644
--- a/modes/tmtv/src/index.ts
+++ b/modes/tmtv/src/index.ts
@@ -17,7 +17,7 @@ const ohif = {
const cs3d = {
viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone',
- segPanel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation',
+ segPanel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentationNoHeader',
};
const tmtv = {
diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js
index 3fec05a88..03a4821b8 100644
--- a/platform/app/public/config/default.js
+++ b/platform/app/public/config/default.js
@@ -16,6 +16,7 @@ window.config = {
experimentalStudyBrowserSort: false,
strictZSpacingForVolumeViewport: true,
groupEnabledModesFirst: true,
+ useExperimentalUI: false,
maxNumRequests: {
interaction: 100,
thumbnail: 75,
diff --git a/platform/app/public/config/netlify.js b/platform/app/public/config/netlify.js
index ed0ca71a6..75c7ad331 100644
--- a/platform/app/public/config/netlify.js
+++ b/platform/app/public/config/netlify.js
@@ -8,6 +8,7 @@ window.config = {
// below flag is for performance reasons, but it might not work for all servers
showWarningMessageForCrossOrigin: true,
showCPUFallbackMessage: true,
+ useExperimentalUI: false,
showLoadingIndicator: true,
experimentalStudyBrowserSort: false,
strictZSpacingForVolumeViewport: true,
diff --git a/platform/core/src/services/PanelService/PanelService.tsx b/platform/core/src/services/PanelService/PanelService.tsx
index 0368b1a6c..4900283e9 100644
--- a/platform/core/src/services/PanelService/PanelService.tsx
+++ b/platform/core/src/services/PanelService/PanelService.tsx
@@ -105,10 +105,13 @@ export default class PanelService extends PubSubService {
entry = panelsData[0].entry;
// stack the content of the panels in one react component
- content = () => (
+ content = props => (
<>
{panelsData.map(({ content: PanelContent }, index) => (
-
+
))}
>
);
diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json
index b1a9f65c5..e70ce54b7 100644
--- a/platform/ui-next/package.json
+++ b/platform/ui-next/package.json
@@ -27,6 +27,7 @@
".": "./src/index.ts"
},
"dependencies": {
+ "@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
diff --git a/platform/ui-next/src/components/Accordion/Accordion.tsx b/platform/ui-next/src/components/Accordion/Accordion.tsx
new file mode 100644
index 000000000..2a8d8622e
--- /dev/null
+++ b/platform/ui-next/src/components/Accordion/Accordion.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import * as React from 'react';
+import * as AccordionPrimitive from '@radix-ui/react-accordion';
+import { ChevronDownIcon } from '@radix-ui/react-icons';
+
+import { cn } from '../../lib/utils';
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = 'AccordionItem';
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-90',
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/platform/ui-next/src/components/Accordion/index.ts b/platform/ui-next/src/components/Accordion/index.ts
new file mode 100644
index 000000000..2a755dd53
--- /dev/null
+++ b/platform/ui-next/src/components/Accordion/index.ts
@@ -0,0 +1,3 @@
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './Accordion';
+
+export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
diff --git a/platform/ui-next/src/components/DisplaySetMessageListTooltip/DisplaySetMessageListTooltip.tsx b/platform/ui-next/src/components/DisplaySetMessageListTooltip/DisplaySetMessageListTooltip.tsx
new file mode 100644
index 000000000..d0e9721b9
--- /dev/null
+++ b/platform/ui-next/src/components/DisplaySetMessageListTooltip/DisplaySetMessageListTooltip.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Icons } from '../Icons';
+import { useTranslation } from 'react-i18next';
+import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../Tooltip';
+
+/**
+ * Displays a tooltip with a list of messages of a displaySet
+ * @param param0
+ * @returns
+ */
+const DisplaySetMessageListTooltip = ({ messages, id }): React.ReactNode => {
+ const { t } = useTranslation('Messages');
+ if (messages?.size()) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {t('Display Set Messages')}
+
+
+ {messages.messages.map((message, index) => (
+ -
+ {index + 1}. {t(message.id)}
+
+ ))}
+
+
+
+
+
+ >
+ );
+ }
+ return <>>;
+};
+
+DisplaySetMessageListTooltip.propTypes = {
+ messages: PropTypes.object,
+ id: PropTypes.string,
+};
+
+export default DisplaySetMessageListTooltip;
diff --git a/platform/ui-next/src/components/DisplaySetMessageListTooltip/index.ts b/platform/ui-next/src/components/DisplaySetMessageListTooltip/index.ts
new file mode 100644
index 000000000..8655260a0
--- /dev/null
+++ b/platform/ui-next/src/components/DisplaySetMessageListTooltip/index.ts
@@ -0,0 +1,3 @@
+import DisplaySetMessageListTooltip from './DisplaySetMessageListTooltip';
+
+export default DisplaySetMessageListTooltip;
diff --git a/platform/ui-next/src/components/Icons/Icons.tsx b/platform/ui-next/src/components/Icons/Icons.tsx
new file mode 100644
index 000000000..ec3aa6d62
--- /dev/null
+++ b/platform/ui-next/src/components/Icons/Icons.tsx
@@ -0,0 +1,1242 @@
+import React from 'react';
+
+type IconProps = React.HTMLAttributes;
+
+export const Icons = {
+ // Usage example:
+
+ ChevronClosed: (props: IconProps) => (
+
+ ),
+ ChevronOpen: (props: IconProps) => (
+
+ ),
+ Download: (props: IconProps) => (
+
+ ),
+ ListView: (props: IconProps) => (
+
+ ),
+ More: (props: IconProps) => (
+
+ ),
+ PinFill: (props: IconProps) => (
+
+ ),
+ Pin: (props: IconProps) => (
+
+ ),
+ ThumbnailView: (props: IconProps) => (
+
+ ),
+ IconMPR: (props: IconProps) => (
+
+ ),
+ InfoSeries: (props: IconProps) => (
+
+ ),
+ StatusTracking: (props: IconProps) => (
+
+ ),
+ StatusWarning: (props: IconProps) => (
+
+ ),
+ SortingAscending: (props: IconProps) => (
+
+ ),
+ SortingDescending: (props: IconProps) => (
+
+ ),
+ Trash: (props: IconProps) => (
+
+ ),
+ Cancel: (props: IconProps) => (
+
+ ),
+ InfoLink: (props: IconProps) => (
+
+ ),
+ NavigationPanelReveal: (props: IconProps) => (
+
+ ),
+ MissingIcon: (props: IconProps) => Missing icon
,
+ Settings: (props: IconProps) => (
+
+ ),
+ SidePanelCloseLeft: (props: IconProps) => (
+
+ ),
+ SidePanelCloseRight: (props: IconProps) => (
+
+ ),
+ TabSegmentation: (props: IconProps) => (
+
+ ),
+ TabLinear: (props: IconProps) => (
+
+ ),
+ TabStudies: (props: IconProps) => (
+
+ ),
+ Tab4D: (props: IconProps) => (
+
+ ),
+ TabPatientInfo: (props: IconProps) => (
+
+ ),
+ TabRoiThreshold: (props: IconProps) => (
+
+ ),
+
+ // Aliases
+ 'tab-segmentation': (props: IconProps) => Icons.TabSegmentation(props),
+ 'tab-studies': (props: IconProps) => Icons.TabStudies(props),
+ 'tab-linear': (props: IconProps) => Icons.TabLinear(props),
+ 'tab-4d': (props: IconProps) => Icons.Tab4D(props),
+ 'tab-patient-info': (props: IconProps) => Icons.TabPatientInfo(props),
+ 'tab-roi-threshold': (props: IconProps) => Icons.TabRoiThreshold(props),
+ 'icon-mpr': (props: IconProps) => Icons.IconMPR(props),
+};
diff --git a/platform/ui-next/src/components/Icons/icon-disclosure-close.svg b/platform/ui-next/src/components/Icons/icon-disclosure-close.svg
new file mode 100644
index 000000000..dd6078c55
--- /dev/null
+++ b/platform/ui-next/src/components/Icons/icon-disclosure-close.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/platform/ui-next/src/components/Icons/icon-disclosure-open.svg b/platform/ui-next/src/components/Icons/icon-disclosure-open.svg
new file mode 100644
index 000000000..7dae7c9ec
--- /dev/null
+++ b/platform/ui-next/src/components/Icons/icon-disclosure-open.svg
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/platform/ui-next/src/components/Icons/index.ts b/platform/ui-next/src/components/Icons/index.ts
new file mode 100644
index 000000000..757c39e0d
--- /dev/null
+++ b/platform/ui-next/src/components/Icons/index.ts
@@ -0,0 +1,3 @@
+import { Icons } from './Icons';
+
+export { Icons };
diff --git a/platform/ui-next/src/components/PanelSection/PanelSection.tsx b/platform/ui-next/src/components/PanelSection/PanelSection.tsx
new file mode 100644
index 000000000..3e58011de
--- /dev/null
+++ b/platform/ui-next/src/components/PanelSection/PanelSection.tsx
@@ -0,0 +1,66 @@
+import React, { useState } from 'react';
+import { Icons } from '../Icons';
+import PropTypes from 'prop-types';
+
+const PanelSection = ({ title, children, actionIcons = [], childrenClassName }) => {
+ const [areChildrenVisible, setChildrenVisible] = useState(true);
+
+ const handleHeaderClick = () => {
+ setChildrenVisible(!areChildrenVisible);
+ };
+
+ return (
+ <>
+
+
{title}
+
+ {actionIcons.map((icon, index) => {
+ const Icon = Icons[icon.name];
+ return (
+
{
+ e.stopPropagation();
+ if (!areChildrenVisible) {
+ setChildrenVisible(true);
+ }
+ icon.onClick();
+ }}
+ />
+ );
+ })}
+
+ {areChildrenVisible ? : }
+
+
+
+ {areChildrenVisible && (
+ <>
+
+
+ {children}
+
+ >
+ )}
+ >
+ );
+};
+
+PanelSection.propTypes = {
+ title: PropTypes.string,
+ children: PropTypes.node,
+ childrenClassName: PropTypes.string,
+ actionIcons: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string,
+ onClick: PropTypes.func,
+ })
+ ),
+};
+
+export default PanelSection;
diff --git a/platform/ui-next/src/components/PanelSection/index.ts b/platform/ui-next/src/components/PanelSection/index.ts
new file mode 100644
index 000000000..32189f310
--- /dev/null
+++ b/platform/ui-next/src/components/PanelSection/index.ts
@@ -0,0 +1,3 @@
+import PanelSection from './PanelSection';
+
+export default PanelSection;
diff --git a/platform/ui-next/src/components/Separator/Separator.tsx b/platform/ui-next/src/components/Separator/Separator.tsx
index 742159b11..c9a836779 100644
--- a/platform/ui-next/src/components/Separator/Separator.tsx
+++ b/platform/ui-next/src/components/Separator/Separator.tsx
@@ -1,24 +1,33 @@
-import * as React from 'react';
-import * as SeparatorPrimitive from '@radix-ui/react-separator';
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from '../../lib/utils';
+type SeparatorProps = React.ComponentPropsWithoutRef & {
+ thickness?: string;
+};
+
const Separator = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
-
-));
-Separator.displayName = SeparatorPrimitive.Root.displayName;
+ SeparatorProps
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, thickness = "1px", ...props },
+ ref
+ ) => (
+
+ )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
-export { Separator };
+export { Separator }
\ No newline at end of file
diff --git a/platform/ui-next/src/components/Separator/index.ts b/platform/ui-next/src/components/Separator/index.ts
new file mode 100644
index 000000000..43685ffac
--- /dev/null
+++ b/platform/ui-next/src/components/Separator/index.ts
@@ -0,0 +1,3 @@
+import { Separator } from "./Separator";
+
+export default Separator;
diff --git a/platform/ui-next/src/components/SidePanel/SidePanel.tsx b/platform/ui-next/src/components/SidePanel/SidePanel.tsx
new file mode 100644
index 000000000..bd3cc95b1
--- /dev/null
+++ b/platform/ui-next/src/components/SidePanel/SidePanel.tsx
@@ -0,0 +1,422 @@
+import classnames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useState } from 'react';
+import { Icons } from '../Icons';
+import { TooltipTrigger, TooltipContent, TooltipProvider, Tooltip } from '../Tooltip';
+import Separator from '../Separator';
+
+type StyleMap = {
+ open: {
+ left: { marginLeft: string };
+ right: { marginRight: string };
+ };
+ closed: {
+ left: { marginLeft: string };
+ right: { marginRight: string };
+ };
+};
+const borderSize = 4;
+const collapsedWidth = 25;
+const closeIconWidth = 30;
+const gridHorizontalPadding = 10;
+const tabSpacerWidth = 2;
+
+const baseClasses =
+ 'transition-all duration-300 ease-in-out bg-black border-black justify-start box-content flex flex-col';
+
+const classesMap = {
+ open: {
+ left: `mr-1`,
+ right: `ml-1`,
+ },
+ closed: {
+ left: `mr-2 items-end`,
+ right: `ml-2 items-start`,
+ },
+};
+
+const openStateIconName = {
+ left: 'SidePanelCloseLeft',
+ right: 'SidePanelCloseRight',
+};
+
+const getTabWidth = (numTabs: number) => {
+ if (numTabs < 3) {
+ return 68;
+ } else {
+ return 40;
+ }
+};
+
+const getGridWidth = (numTabs: number, gridAvailableWidth: number) => {
+ const spacersWidth = (numTabs - 1) * tabSpacerWidth;
+ const tabsWidth = getTabWidth(numTabs) * numTabs;
+
+ if (gridAvailableWidth > tabsWidth + spacersWidth) {
+ return tabsWidth + spacersWidth;
+ }
+
+ return gridAvailableWidth;
+};
+
+const getNumGridColumns = (numTabs: number, gridWidth: number) => {
+ if (numTabs === 1) {
+ return 1;
+ }
+
+ // Start by calculating the number of tabs assuming each tab was accompanied by a spacer.
+ const tabWidth = getTabWidth(numTabs);
+ const numTabsWithOneSpacerEach = Math.floor(gridWidth / (tabWidth + tabSpacerWidth));
+
+ // But there is always one less spacer than tabs, so now check if an extra tab with one less spacer fits.
+ if (
+ (numTabsWithOneSpacerEach + 1) * tabWidth + numTabsWithOneSpacerEach * tabSpacerWidth <=
+ gridWidth
+ ) {
+ return numTabsWithOneSpacerEach + 1;
+ }
+
+ return numTabsWithOneSpacerEach;
+};
+
+const getTabClassNames = (
+ numColumns: number,
+ numTabs: number,
+ tabIndex: number,
+ isActiveTab: boolean,
+ isTabDisabled: boolean
+) =>
+ classnames('h-[28px] mb-[2px] cursor-pointer text-white bg-black', {
+ 'hover:text-primary-active': !isActiveTab && !isTabDisabled,
+ 'rounded-l': tabIndex % numColumns === 0,
+ 'rounded-r': (tabIndex + 1) % numColumns === 0 || tabIndex === numTabs - 1,
+ });
+
+const getTabStyle = (numTabs: number) => {
+ return {
+ width: `${getTabWidth(numTabs)}px`,
+ };
+};
+
+const getTabIconClassNames = (numTabs: number, isActiveTab: boolean) => {
+ return classnames('h-full w-full flex items-center justify-center', {
+ 'bg-customblue-40': isActiveTab,
+ rounded: isActiveTab,
+ });
+};
+const createStyleMap = (
+ expandedWidth: number,
+ borderSize: number,
+ collapsedWidth: number
+): StyleMap => {
+ const collapsedHideWidth = expandedWidth - collapsedWidth - borderSize;
+
+ return {
+ open: {
+ left: { marginLeft: '0px' },
+ right: { marginRight: '0px' },
+ },
+ closed: {
+ left: { marginLeft: `-${collapsedHideWidth}px` },
+ right: { marginRight: `-${collapsedHideWidth}px` },
+ },
+ };
+};
+
+const getToolTipContent = (label: string, disabled: boolean) => {
+ return (
+ <>
+ {label}
+ {disabled && {'Not available based on current context'}
}
+ >
+ );
+};
+
+const createBaseStyle = (expandedWidth: number) => {
+ return {
+ maxWidth: `${expandedWidth}px`,
+ width: `${expandedWidth}px`,
+ // To align the top of the side panel with the top of the viewport grid, use position relative and offset the
+ // top by the same top offset as the viewport grid. Also adjust the height so that there is no overflow.
+ position: 'relative',
+ top: '0.2%',
+ height: '99.8%',
+ };
+};
+const SidePanel = ({
+ side,
+ className,
+ activeTabIndex: activeTabIndexProp = null,
+ tabs,
+ onOpen,
+ expandedWidth = 280,
+ onActiveTabIndexChange,
+}) => {
+ const [panelOpen, setPanelOpen] = useState(activeTabIndexProp !== null);
+ const [renderHeader, setRenderHeader] = useState(false);
+ const [activeTabIndex, setActiveTabIndex] = useState(0);
+
+ const styleMap = createStyleMap(expandedWidth, borderSize, collapsedWidth);
+ const baseStyle = createBaseStyle(expandedWidth);
+ const gridAvailableWidth = expandedWidth - closeIconWidth - gridHorizontalPadding;
+ const gridWidth = getGridWidth(tabs.length, gridAvailableWidth);
+ const openStatus = panelOpen ? 'open' : 'closed';
+ const style = Object.assign({}, styleMap[openStatus][side], baseStyle);
+
+ const updatePanelOpen = useCallback(
+ (panelOpen: boolean) => {
+ setPanelOpen(panelOpen);
+ if (panelOpen && onOpen) {
+ onOpen();
+ }
+ },
+ [onOpen]
+ );
+
+ const updateActiveTabIndex = useCallback(
+ (activeTabIndex: number) => {
+ if (activeTabIndex === null) {
+ updatePanelOpen(false);
+ return;
+ }
+
+ setActiveTabIndex(activeTabIndex);
+ updatePanelOpen(true);
+
+ if (onActiveTabIndexChange) {
+ onActiveTabIndexChange({ activeTabIndex });
+ }
+ },
+ [onActiveTabIndexChange, updatePanelOpen]
+ );
+
+ useEffect(() => {
+ setRenderHeader(tabs.length === 1);
+ }, [tabs]);
+ useEffect(() => {
+ updateActiveTabIndex(activeTabIndexProp);
+ }, [activeTabIndexProp, updateActiveTabIndex]);
+
+ const getCloseStateComponent = () => {
+ const _childComponents = Array.isArray(tabs) ? tabs : [tabs];
+ return (
+ <>
+ {
+ updatePanelOpen(!panelOpen);
+ }}
+ data-cy={`side-panel-header-${side}`}
+ >
+
+
+
+
+ {_childComponents.map((childComponent, index) => (
+
+
+ {
+ return childComponent.disabled ? null : updateActiveTabIndex(index);
+ }}
+ >
+ {React.createElement(Icons[childComponent.iconName] || Icons.MissingIcon, {
+ className: classnames({
+ 'text-primary-active': true,
+ 'ohif-disabled': childComponent.disabled,
+ }),
+ style: {
+ width: '22px',
+ height: '22px',
+ },
+ })}
+
+
+
+
+ {getToolTipContent(childComponent.label, childComponent.disabled)}
+
+
+
+ ))}
+
+
+ >
+ );
+ };
+
+ const getCloseIcon = () => {
+ return (
+ {
+ updatePanelOpen(!panelOpen);
+ }}
+ data-cy={`side-panel-header-${side}`}
+ >
+ {React.createElement(Icons[openStateIconName[side]] || Icons.MissingIcon, {
+ className: 'text-primary-active',
+ })}
+
+ );
+ };
+
+ const getTabGridComponent = () => {
+ const numCols = getNumGridColumns(tabs.length, gridWidth);
+
+ return (
+ <>
+ {getCloseIcon()}
+
+
+
+ {tabs.map((tab, tabIndex) => {
+ const { disabled } = tab;
+ return (
+
+ {tabIndex % numCols !== 0 && (
+
+ )}
+
+
+
+ {
+ return disabled ? null : updateActiveTabIndex(tabIndex);
+ }}
+ data-cy={`${tab.name}-btn`}
+ >
+
+ {React.createElement(Icons[tab.iconName] || Icons.MissingIcon, {
+ className: classnames({
+ 'text-primary-active': true,
+ 'ohif-disabled': disabled,
+ }),
+ style: {
+ width: '22px',
+ height: '22px',
+ },
+ })}
+
+
+
+
+ {getToolTipContent(tab.label, disabled)}
+
+
+
+
+ );
+ })}
+
+
+ >
+ );
+ };
+
+ const getOpenStateComponent = () => {
+ if (tabs.length === 1) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {getTabGridComponent()}
+
+
+ >
+ );
+ };
+
+ return (
+
+ {panelOpen ? (
+ <>
+ {getOpenStateComponent()}
+ {tabs.map((tab, tabIndex) => {
+ if (tabIndex === activeTabIndex) {
+ return (
+
+ );
+ }
+ return null;
+ })}
+ >
+ ) : (
+ {getCloseStateComponent()}
+ )}
+
+ );
+};
+
+SidePanel.propTypes = {
+ side: PropTypes.oneOf(['left', 'right']).isRequired,
+ className: PropTypes.string,
+ activeTabIndex: PropTypes.number,
+ tabs: PropTypes.oneOfType([
+ PropTypes.arrayOf(
+ PropTypes.shape({
+ iconName: PropTypes.string.isRequired,
+ iconLabel: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ content: PropTypes.func, // TODO: Should be node, but it keeps complaining?
+ })
+ ),
+ ]),
+ onOpen: PropTypes.func,
+ onActiveTabIndexChange: PropTypes.func,
+ expandedWidth: PropTypes.number,
+};
+
+export default SidePanel;
diff --git a/platform/ui-next/src/components/SidePanel/index.ts b/platform/ui-next/src/components/SidePanel/index.ts
new file mode 100644
index 000000000..68034ac14
--- /dev/null
+++ b/platform/ui-next/src/components/SidePanel/index.ts
@@ -0,0 +1,2 @@
+import SidePanel from './SidePanel';
+export default SidePanel;
diff --git a/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx b/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx
new file mode 100644
index 000000000..a419107e1
--- /dev/null
+++ b/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx
@@ -0,0 +1,143 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import StudyItem from '../StudyItem';
+import StudyBrowserSort from '../StudyBrowserSort';
+import StudyBrowserViewOptions from '../StudyBrowserViewOptions';
+
+const getTrackedSeries = displaySets => {
+ let trackedSeries = 0;
+ displaySets.forEach(displaySet => {
+ if (displaySet.isTracked) {
+ trackedSeries++;
+ }
+ });
+
+ return trackedSeries;
+};
+
+const noop = () => {};
+
+const StudyBrowser = ({
+ tabs,
+ activeTabName,
+ expandedStudyInstanceUIDs,
+ onClickTab = noop,
+ onClickStudy = noop,
+ onClickThumbnail = noop,
+ onDoubleClickThumbnail = noop,
+ onClickUntrack = noop,
+ activeDisplaySetInstanceUIDs,
+ servicesManager,
+ showSettings,
+ viewPresets,
+}: withAppTypes) => {
+ const getTabContent = () => {
+ const tabData = tabs.find(tab => tab.name === activeTabName);
+ const viewPreset = viewPresets
+ ? viewPresets.filter(preset => preset.selected)[0]?.id
+ : 'thumbnails';
+ return tabData.studies.map(
+ ({ studyInstanceUid, date, description, numInstances, modalities, displaySets }) => {
+ const isExpanded = expandedStudyInstanceUIDs.includes(studyInstanceUid);
+ return (
+
+ {
+ onClickStudy(studyInstanceUid);
+ }}
+ onClickThumbnail={onClickThumbnail}
+ onDoubleClickThumbnail={onDoubleClickThumbnail}
+ onClickUntrack={onClickUntrack}
+ activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
+ data-cy="thumbnail-list"
+ viewPreset={viewPreset}
+ />
+
+ );
+ }
+ );
+ };
+
+ return (
+
+ {showSettings && (
+
+
+
+
+ )}
+
+ {getTabContent()}
+
+
+ );
+};
+
+StudyBrowser.propTypes = {
+ onClickTab: PropTypes.func.isRequired,
+ onClickStudy: PropTypes.func,
+ onClickThumbnail: PropTypes.func,
+ onDoubleClickThumbnail: PropTypes.func,
+ onClickUntrack: PropTypes.func,
+ activeTabName: PropTypes.string.isRequired,
+ expandedStudyInstanceUIDs: PropTypes.arrayOf(PropTypes.string).isRequired,
+ activeDisplaySetInstanceUIDs: PropTypes.arrayOf(PropTypes.string),
+ tabs: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ studies: PropTypes.arrayOf(
+ PropTypes.shape({
+ studyInstanceUid: PropTypes.string.isRequired,
+ date: PropTypes.string,
+ numInstances: PropTypes.number,
+ modalities: PropTypes.string,
+ description: PropTypes.string,
+ displaySets: PropTypes.arrayOf(
+ PropTypes.shape({
+ displaySetInstanceUID: PropTypes.string.isRequired,
+ imageSrc: PropTypes.string,
+ imageAltText: PropTypes.string,
+ seriesDate: PropTypes.string,
+ seriesNumber: PropTypes.any,
+ numInstances: PropTypes.number,
+ description: PropTypes.string,
+ componentType: PropTypes.oneOf(['thumbnail', 'thumbnailTracked', 'thumbnailNoImage'])
+ .isRequired,
+ isTracked: PropTypes.bool,
+ /**
+ * Data the thumbnail should expose to a receiving drop target. Use a matching
+ * `dragData.type` to identify which targets can receive this draggable item.
+ * If this is not set, drag-n-drop will be disabled for this thumbnail.
+ *
+ * Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
+ */
+ dragData: PropTypes.shape({
+ /** Must match the "type" a dropTarget expects */
+ type: PropTypes.string.isRequired,
+ }),
+ })
+ ),
+ })
+ ).isRequired,
+ })
+ ),
+};
+
+export default StudyBrowser;
diff --git a/platform/ui-next/src/components/StudyBrowser/index.ts b/platform/ui-next/src/components/StudyBrowser/index.ts
new file mode 100644
index 000000000..aa85fa066
--- /dev/null
+++ b/platform/ui-next/src/components/StudyBrowser/index.ts
@@ -0,0 +1,2 @@
+import StudyBrowser from './StudyBrowser';
+export default StudyBrowser;
diff --git a/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx b/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx
new file mode 100644
index 000000000..f1d38ad93
--- /dev/null
+++ b/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx
@@ -0,0 +1,75 @@
+import React, { useEffect, useState } from 'react';
+import { Icons } from '../Icons';
+
+export default function StudyBrowserSort({ servicesManager }: withAppTypes) {
+ const { customizationService, displaySetService } = servicesManager.services;
+ const { values: sortFunctions } = customizationService.get('studyBrowser.sortFunctions');
+
+ const [selectedSort, setSelectedSort] = useState(sortFunctions[0]);
+ const [sortDirection, setSortDirection] = useState('ascending');
+
+ const handleSortChange = event => {
+ const selectedSortFunction = sortFunctions.find(sort => sort.label === event.target.value);
+ setSelectedSort(selectedSortFunction);
+ };
+
+ const toggleSortDirection = e => {
+ e.stopPropagation();
+ setSortDirection(prevDirection => (prevDirection === 'ascending' ? 'descending' : 'ascending'));
+ };
+
+ useEffect(() => {
+ displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection);
+ }, [displaySetService, selectedSort, sortDirection]);
+
+ useEffect(() => {
+ const SubscriptionDisplaySetsChanged = displaySetService.subscribe(
+ displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
+ () => {
+ displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection, true);
+ }
+ );
+ const SubscriptionDisplaySetMetaDataInvalidated = displaySetService.subscribe(
+ displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
+ () => {
+ displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection, true);
+ }
+ );
+
+ return () => {
+ SubscriptionDisplaySetsChanged.unsubscribe();
+ SubscriptionDisplaySetMetaDataInvalidated.unsubscribe();
+ };
+ }, [displaySetService, selectedSort, sortDirection]);
+
+ return (
+
+
+
+
+ );
+}
diff --git a/platform/ui-next/src/components/StudyBrowserSort/index.ts b/platform/ui-next/src/components/StudyBrowserSort/index.ts
new file mode 100644
index 000000000..1f9a4601e
--- /dev/null
+++ b/platform/ui-next/src/components/StudyBrowserSort/index.ts
@@ -0,0 +1,3 @@
+import StudyBrowserSort from './StudyBrowserSort';
+
+export default StudyBrowserSort;
diff --git a/platform/ui-next/src/components/StudyBrowserViewOptions/StudyBrowserViewOptions.tsx b/platform/ui-next/src/components/StudyBrowserViewOptions/StudyBrowserViewOptions.tsx
new file mode 100644
index 000000000..05dfd0be4
--- /dev/null
+++ b/platform/ui-next/src/components/StudyBrowserViewOptions/StudyBrowserViewOptions.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+
+export default function StudyBrowserViewOptions({
+ tabs,
+ onSelectTab,
+ activeTabName,
+}: withAppTypes) {
+ const handleTabChange = (tabName: string) => {
+ onSelectTab(tabName);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/platform/ui-next/src/components/StudyBrowserViewOptions/index.ts b/platform/ui-next/src/components/StudyBrowserViewOptions/index.ts
new file mode 100644
index 000000000..d2193c6ad
--- /dev/null
+++ b/platform/ui-next/src/components/StudyBrowserViewOptions/index.ts
@@ -0,0 +1,3 @@
+import StudyBrowserViewOptions from './StudyBrowserViewOptions';
+
+export default StudyBrowserViewOptions;
diff --git a/platform/ui-next/src/components/StudyItem/StudyItem.tsx b/platform/ui-next/src/components/StudyItem/StudyItem.tsx
new file mode 100644
index 000000000..977db0871
--- /dev/null
+++ b/platform/ui-next/src/components/StudyItem/StudyItem.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import ThumbnailList from '../ThumbnailList';
+
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../Accordion';
+
+const StudyItem = ({
+ date,
+ description,
+ numInstances,
+ modalities,
+ isActive,
+ onClick,
+ isExpanded,
+ displaySets,
+ activeDisplaySetInstanceUIDs,
+ onClickThumbnail,
+ onDoubleClickThumbnail,
+ onClickUntrack,
+ viewPreset = 'thumbnails',
+}) => {
+ return (
+
+
+
+
+
+
+
{date}
+
+ {description}
+
+
+
+
{modalities}
+
{numInstances}
+
+
+
+
+ {
+ event.stopPropagation();
+ }}
+ >
+ {isExpanded && displaySets && (
+
+ )}
+
+
+
+ );
+};
+
+StudyItem.propTypes = {
+ date: PropTypes.string.isRequired,
+ description: PropTypes.string,
+ modalities: PropTypes.string.isRequired,
+ numInstances: PropTypes.number.isRequired,
+ trackedSeries: PropTypes.number,
+ isActive: PropTypes.bool,
+ onClick: PropTypes.func.isRequired,
+ isExpanded: PropTypes.bool,
+ displaySets: PropTypes.array,
+ activeDisplaySetInstanceUIDs: PropTypes.array,
+ onClickThumbnail: PropTypes.func,
+ onDoubleClickThumbnail: PropTypes.func,
+ onClickUntrack: PropTypes.func,
+ viewPreset: PropTypes.string,
+};
+
+export default StudyItem;
diff --git a/platform/ui-next/src/components/StudyItem/index.ts b/platform/ui-next/src/components/StudyItem/index.ts
new file mode 100644
index 000000000..0c9680c4f
--- /dev/null
+++ b/platform/ui-next/src/components/StudyItem/index.ts
@@ -0,0 +1,2 @@
+import StudyItem from './StudyItem';
+export default StudyItem;
diff --git a/platform/ui-next/src/components/Tabs/index.ts b/platform/ui-next/src/components/Tabs/index.ts
new file mode 100644
index 000000000..f5be4be6d
--- /dev/null
+++ b/platform/ui-next/src/components/Tabs/index.ts
@@ -0,0 +1,3 @@
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs"
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx b/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx
new file mode 100644
index 000000000..387036236
--- /dev/null
+++ b/platform/ui-next/src/components/Thumbnail/Thumbnail.tsx
@@ -0,0 +1,294 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { useDrag } from 'react-dnd';
+import { Icons } from '../Icons';
+import DisplaySetMessageListTooltip from '../DisplaySetMessageListTooltip';
+import { TooltipTrigger, TooltipContent, TooltipProvider, Tooltip } from '../Tooltip';
+
+/**
+ * Display a thumbnail for a display set.
+ */
+const Thumbnail = ({
+ displaySetInstanceUID,
+ className,
+ imageSrc,
+ imageAltText,
+ description,
+ seriesNumber,
+ numInstances,
+ loadingProgress,
+ countIcon,
+ messages,
+ dragData = {},
+ isActive,
+ onClick,
+ onDoubleClick,
+ viewPreset = 'thumbnails',
+ modality,
+ isHydratedForDerivedDisplaySet = false,
+ canReject = false,
+ onReject = () => {},
+ isTracked = false,
+ onClickUntrack = () => {},
+}): React.ReactNode => {
+ // TODO: We should wrap our thumbnail to create a "DraggableThumbnail", as
+ // this will still allow for "drag", even if there is no drop target for the
+ // specified item.
+ const [collectedProps, drag, dragPreview] = useDrag({
+ type: 'displayset',
+ item: { ...dragData },
+ canDrag: function (monitor) {
+ return Object.keys(dragData).length !== 0;
+ },
+ });
+
+ const [lastTap, setLastTap] = useState(0);
+
+ const handleTouchEnd = e => {
+ const currentTime = new Date().getTime();
+ const tapLength = currentTime - lastTap;
+ if (tapLength < 300 && tapLength > 0) {
+ onDoubleClick(e);
+ } else {
+ onClick(e);
+ }
+ setLastTap(currentTime);
+ };
+
+ const renderThumbnailPreset = () => {
+ return (
+
+
+
+ {imageSrc ? (
+

+ ) : (
+
+ )}
+
+ {/* bottom left */}
+
+
+ {/* top right */}
+
+
+ {canReject && (
+
+ )}
+ {isTracked && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isTracked ? 'Series is tracked' : 'Series is untracked'}
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
{description}
+
+
S:{seriesNumber}
+
+
+ {' '}
+ {countIcon ? (
+ React.createElement(Icons[countIcon] || Icons.MissingIcon, { className: 'w-3' })
+ ) : (
+
+ )}
+
{numInstances}
+
+
+
+
+
+ );
+ };
+
+ const renderListPreset = () => {
+ return (
+
+
+
+
+
+
{modality}
+
+
+ {description}
+
+
+
+
+
S:{seriesNumber}
+
+
+ {' '}
+ {countIcon ? (
+ React.createElement(Icons[countIcon] || Icons.MissingIcon, { className: 'w-3' })
+ ) : (
+
+ )}
+
{numInstances}
+
+
+
+
+
+
+
+ {canReject && (
+
+ )}
+ {isTracked && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isTracked ? 'Series is tracked' : 'Series is untracked'}
+
+
+
+
+
+
+
+ )}
+
+
+ );
+ };
+
+ return (
+
+
+ {viewPreset === 'thumbnails' && renderThumbnailPreset()}
+ {viewPreset === 'list' && renderListPreset()}
+
+
+ );
+};
+
+Thumbnail.propTypes = {
+ displaySetInstanceUID: PropTypes.string.isRequired,
+ className: PropTypes.string,
+ imageSrc: PropTypes.string,
+ /**
+ * Data the thumbnail should expose to a receiving drop target. Use a matching
+ * `dragData.type` to identify which targets can receive this draggable item.
+ * If this is not set, drag-n-drop will be disabled for this thumbnail.
+ *
+ * Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
+ */
+ dragData: PropTypes.shape({
+ /** Must match the "type" a dropTarget expects */
+ type: PropTypes.string.isRequired,
+ }),
+ imageAltText: PropTypes.string,
+ description: PropTypes.string.isRequired,
+ seriesNumber: PropTypes.any,
+ numInstances: PropTypes.number.isRequired,
+ loadingProgress: PropTypes.number,
+ messages: PropTypes.object,
+ isActive: PropTypes.bool.isRequired,
+ onClick: PropTypes.func.isRequired,
+ onDoubleClick: PropTypes.func.isRequired,
+ viewPreset: PropTypes.string,
+ modality: PropTypes.string,
+ isHydratedForDerivedDisplaySet: PropTypes.bool,
+ canReject: PropTypes.bool,
+ onReject: PropTypes.func,
+ isTracked: PropTypes.bool,
+ onClickUntrack: PropTypes.func,
+ countIcon: PropTypes.string,
+};
+
+export default Thumbnail;
diff --git a/platform/ui-next/src/components/Thumbnail/index.ts b/platform/ui-next/src/components/Thumbnail/index.ts
new file mode 100644
index 000000000..275561d01
--- /dev/null
+++ b/platform/ui-next/src/components/Thumbnail/index.ts
@@ -0,0 +1,2 @@
+import Thumbnail from './Thumbnail';
+export default Thumbnail;
diff --git a/platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx b/platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx
new file mode 100644
index 000000000..f0b0179f0
--- /dev/null
+++ b/platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx
@@ -0,0 +1,162 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Thumbnail from '../Thumbnail';
+
+const ThumbnailList = ({
+ thumbnails,
+ onThumbnailClick,
+ onThumbnailDoubleClick,
+ onClickUntrack,
+ activeDisplaySetInstanceUIDs = [],
+ viewPreset,
+}) => {
+ return (
+
+ {thumbnails.map(
+ ({
+ displaySetInstanceUID,
+ description,
+ dragData,
+ seriesNumber,
+ numInstances,
+ loadingProgress,
+ modality,
+ componentType,
+ seriesDate,
+ countIcon,
+ isTracked,
+ canReject,
+ onReject,
+ imageSrc,
+ messages,
+ imageAltText,
+ isHydratedForDerivedDisplaySet,
+ }) => {
+ const isActive = activeDisplaySetInstanceUIDs.includes(displaySetInstanceUID);
+ switch (componentType) {
+ case 'thumbnail':
+ return (
+ onThumbnailClick(displaySetInstanceUID)}
+ onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
+ viewPreset={viewPreset}
+ modality={modality}
+ />
+ );
+ case 'thumbnailTracked':
+ return (
+ onThumbnailClick(displaySetInstanceUID)}
+ onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
+ onClickUntrack={() => onClickUntrack(displaySetInstanceUID)}
+ viewPreset={viewPreset}
+ modality={modality}
+ />
+ );
+ case 'thumbnailNoImage':
+ return (
+ onThumbnailClick(displaySetInstanceUID)}
+ onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
+ viewPreset={viewPreset}
+ countIcon={countIcon}
+ seriesNumber={seriesNumber}
+ numInstances={numInstances || 1}
+ isHydratedForDerivedDisplaySet={isHydratedForDerivedDisplaySet}
+ canReject={canReject}
+ onReject={onReject}
+ />
+ );
+ default:
+ return <>>;
+ }
+ }
+ )}
+
+ );
+};
+
+ThumbnailList.propTypes = {
+ thumbnails: PropTypes.arrayOf(
+ PropTypes.shape({
+ displaySetInstanceUID: PropTypes.string.isRequired,
+ imageSrc: PropTypes.string,
+ imageAltText: PropTypes.string,
+ seriesDate: PropTypes.string,
+ seriesNumber: PropTypes.any,
+ numInstances: PropTypes.number,
+ description: PropTypes.string,
+ componentType: PropTypes.any,
+ isTracked: PropTypes.bool,
+ /**
+ * Data the thumbnail should expose to a receiving drop target. Use a matching
+ * `dragData.type` to identify which targets can receive this draggable item.
+ * If this is not set, drag-n-drop will be disabled for this thumbnail.
+ *
+ * Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
+ */
+ dragData: PropTypes.shape({
+ /** Must match the "type" a dropTarget expects */
+ type: PropTypes.string.isRequired,
+ }),
+ })
+ ),
+ activeDisplaySetInstanceUIDs: PropTypes.arrayOf(PropTypes.string),
+ onThumbnailClick: PropTypes.func.isRequired,
+ onThumbnailDoubleClick: PropTypes.func.isRequired,
+ onClickUntrack: PropTypes.func.isRequired,
+ viewPreset: PropTypes.string,
+};
+
+// TODO: Support "Viewport Identificator"?
+function _getModalityTooltip(modality) {
+ if (_modalityTooltips.hasOwnProperty(modality)) {
+ return _modalityTooltips[modality];
+ }
+
+ return 'Unknown';
+}
+
+const _modalityTooltips = {
+ SR: 'Structured Report',
+ SEG: 'Segmentation',
+ OT: 'Other',
+ RTSTRUCT: 'RT Structure Set',
+};
+
+export default ThumbnailList;
diff --git a/platform/ui-next/src/components/ThumbnailList/index.ts b/platform/ui-next/src/components/ThumbnailList/index.ts
new file mode 100644
index 000000000..e2cfade78
--- /dev/null
+++ b/platform/ui-next/src/components/ThumbnailList/index.ts
@@ -0,0 +1,2 @@
+import ThumbnailList from './ThumbnailList';
+export default ThumbnailList;
diff --git a/platform/ui-next/src/components/Toggle/Toggle.tsx b/platform/ui-next/src/components/Toggle/Toggle.tsx
index 01a4bfd1b..d6e8d0d9b 100644
--- a/platform/ui-next/src/components/Toggle/Toggle.tsx
+++ b/platform/ui-next/src/components/Toggle/Toggle.tsx
@@ -9,12 +9,12 @@ const toggleVariants = cva(
{
variants: {
variant: {
- default: 'bg-transparent',
+ default: 'bg-transparent hover:text-primary',
outline:
'border border-input bg-transparent shadow-sm hover:bg-primary/20 hover:text-primary-foreground',
},
size: {
- default: 'h-9 px-3 mx-0.5',
+ default: 'h-[24px] w-[28px]',
sm: 'h-8 px-2',
lg: 'h-10 px-3',
},
diff --git a/platform/ui-next/src/components/Toggle/index.ts b/platform/ui-next/src/components/Toggle/index.ts
new file mode 100644
index 000000000..e96de617a
--- /dev/null
+++ b/platform/ui-next/src/components/Toggle/index.ts
@@ -0,0 +1,3 @@
+import { Toggle, toggleVariants } from "./Toggle";
+
+export { Toggle, toggleVariants };
diff --git a/platform/ui-next/src/components/ToggleGroup/ToggleGroup.tsx b/platform/ui-next/src/components/ToggleGroup/ToggleGroup.tsx
new file mode 100644
index 000000000..b2d878969
--- /dev/null
+++ b/platform/ui-next/src/components/ToggleGroup/ToggleGroup.tsx
@@ -0,0 +1,55 @@
+import * as React from 'react';
+import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
+import { VariantProps } from 'class-variance-authority';
+
+import { cn } from '../../lib/utils';
+import { toggleVariants } from '../Toggle';
+
+const ToggleGroupContext = React.createContext>({
+ size: 'default',
+ variant: 'default',
+});
+
+const ToggleGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, size, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
+
+const ToggleGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, children, variant, size, ...props }, ref) => {
+ const context = React.useContext(ToggleGroupContext);
+
+ return (
+
+ {children}
+
+ );
+});
+
+ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
+
+export { ToggleGroup, ToggleGroupItem };
diff --git a/platform/ui-next/src/components/ToggleGroup/index.ts b/platform/ui-next/src/components/ToggleGroup/index.ts
new file mode 100644
index 000000000..8e54b5957
--- /dev/null
+++ b/platform/ui-next/src/components/ToggleGroup/index.ts
@@ -0,0 +1,3 @@
+import { ToggleGroup, ToggleGroupItem } from "./ToggleGroup";
+
+export { ToggleGroup, ToggleGroupItem };
diff --git a/platform/ui-next/src/components/Toolbox/Toolbox.tsx b/platform/ui-next/src/components/Toolbox/Toolbox.tsx
new file mode 100644
index 000000000..726de8a41
--- /dev/null
+++ b/platform/ui-next/src/components/Toolbox/Toolbox.tsx
@@ -0,0 +1,183 @@
+import React, { useEffect, useRef } from 'react';
+import { useToolbar } from '@ohif/core';
+import { ToolboxUI } from './';
+// Migrate this file to the new UI eventually
+import { useToolbox } from '@ohif/ui';
+import Separator from '../Separator';
+
+/**
+ * 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,
+ renderHeader,
+ getCloseIcon,
+ tab,
+ ...props
+}: withAppTypes) {
+ const { state: toolboxState, api } = useToolbox(buttonSectionId);
+ const { onInteraction, toolbarButtons } = useToolbar({
+ servicesManager,
+ buttonSection: buttonSectionId,
+ });
+
+ const prevButtonIdsRef = useRef('');
+ const prevToolboxStateRef = 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}`;
+ })
+ );
+
+ const currentToolBoxStateStr = JSON.stringify(
+ Object.keys(toolboxState.toolOptions).map(tool => {
+ const options = toolboxState.toolOptions[tool];
+ if (Array.isArray(options)) {
+ return options?.map(option => `${option.id}-${option.value}`);
+ }
+ })
+ );
+
+ if (
+ prevButtonIdsRef.current === currentButtonIdsStr &&
+ prevToolboxStateRef.current === currentToolBoxStateStr
+ ) {
+ return;
+ }
+
+ prevButtonIdsRef.current = currentButtonIdsStr;
+ prevToolboxStateRef.current = currentToolBoxStateStr;
+
+ const initializeOptionsWithEnhancements = toolbarButtons.reduce(
+ (accumulator, toolbarButton) => {
+ const { id: buttonId, componentProps } = toolbarButton;
+
+ const createEnhancedOptions = (options, parentId) => {
+ const optionsToUse = Array.isArray(options) ? options : [options];
+
+ return optionsToUse.map(option => {
+ if (typeof option.optionComponent === 'function') {
+ return option;
+ }
+
+ const value =
+ toolboxState.toolOptions?.[parentId]?.find(prop => prop.id === option.id)?.value ??
+ option.value;
+
+ const updatedOptions = toolboxState.toolOptions?.[parentId];
+
+ return {
+ ...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';
+ const isFunction = typeof command === 'function';
+
+ if (isString) {
+ commandsManager.run(command, { value });
+ } else if (isObject) {
+ commandsManager.run({
+ ...command,
+ commandOptions: {
+ ...command.commandOptions,
+ ...option,
+ value,
+ options: updatedOptions,
+ },
+ });
+ } else if (isFunction) {
+ command({ value, commandsManager, servicesManager, options: updatedOptions });
+ }
+ });
+ },
+ };
+ });
+ };
+
+ const { items, options } = componentProps;
+
+ if (items?.length) {
+ items.forEach(({ options, id }) => {
+ accumulator[id] = createEnhancedOptions(options, id);
+ });
+ } else if (options?.length) {
+ accumulator[buttonId] = createEnhancedOptions(options, buttonId);
+ } else if (options?.optionComponent) {
+ accumulator[buttonId] = options.optionComponent;
+ }
+
+ return accumulator;
+ },
+ {}
+ );
+
+ api.initializeToolOptions(initializeOptionsWithEnhancements);
+ }, [toolbarButtons, api, toolboxState, commandsManager, servicesManager]);
+
+ const handleToolOptionChange = (toolName, optionName, newValue) => {
+ api.handleToolOptionChange(toolName, optionName, newValue);
+ };
+
+ useEffect(() => {
+ return () => {
+ api.handleToolSelect(null);
+ };
+ }, []);
+
+ return (
+ <>
+ {renderHeader && (
+ <>
+
+
+ >
+ )}
+ api.handleToolSelect(id)}
+ handleToolOptionChange={handleToolOptionChange}
+ onInteraction={onInteraction}
+ />
+ >
+ );
+}
+
+export default Toolbox;
diff --git a/platform/ui-next/src/components/Toolbox/ToolboxUI.tsx b/platform/ui-next/src/components/Toolbox/ToolboxUI.tsx
new file mode 100644
index 000000000..00c381b4e
--- /dev/null
+++ b/platform/ui-next/src/components/Toolbox/ToolboxUI.tsx
@@ -0,0 +1,126 @@
+import React, { useEffect, useRef } from 'react';
+import { PanelSection } from '../../components';
+// Migrate this file to the new UI eventually
+import { ToolSettings } from '@ohif/ui';
+import classnames from 'classnames';
+
+const ItemsPerRow = 4;
+
+function usePrevious(value) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+}
+
+/**
+ * Just refactoring from the toolbox component to make it more readable
+ */
+function ToolboxUI(props: withAppTypes) {
+ const {
+ toolbarButtons,
+ handleToolSelect,
+ toolboxState,
+ numRows,
+ servicesManager,
+ title,
+ useCollapsedPanel = true,
+ } = props;
+
+ const { activeTool, toolOptions, selectedEvent } = toolboxState;
+ const activeToolOptions = toolOptions?.[activeTool];
+
+ const prevToolOptions = usePrevious(activeToolOptions);
+
+ useEffect(() => {
+ if (!activeToolOptions || Array.isArray(activeToolOptions) === false) {
+ return;
+ }
+
+ activeToolOptions.forEach((option, index) => {
+ const prevOption = prevToolOptions ? prevToolOptions[index] : undefined;
+ if (!prevOption || option.value !== prevOption.value || selectedEvent) {
+ const isOptionValid = option.condition
+ ? option.condition({ options: activeToolOptions })
+ : true;
+ if (isOptionValid) {
+ const { commands } = option;
+ commands(option.value);
+ }
+ }
+ });
+ }, [activeToolOptions, selectedEvent]);
+
+ const render = () => {
+ return (
+ <>
+
+
+ {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 }) => {
+ const idToUse = itemId || id;
+ handleToolSelect(idToUse);
+ props.onInteraction({
+ itemId,
+ commands,
+ });
+ };
+
+ return (
+
+ );
+ })}
+
+
+
+ {activeToolOptions && }
+
+ >
+ );
+ };
+
+ return (
+ <>
+ {useCollapsedPanel ? (
+
+ {render()}
+
+ ) : (
+ render()
+ )}
+ >
+ );
+}
+
+export { ToolboxUI };
diff --git a/platform/ui-next/src/components/Toolbox/index.ts b/platform/ui-next/src/components/Toolbox/index.ts
new file mode 100644
index 000000000..e5c0d5de7
--- /dev/null
+++ b/platform/ui-next/src/components/Toolbox/index.ts
@@ -0,0 +1,3 @@
+import { ToolboxUI } from './ToolboxUI';
+import Toolbox from './Toolbox';
+export { ToolboxUI, Toolbox };
diff --git a/platform/ui-next/src/components/Tooltip/Tooltip.tsx b/platform/ui-next/src/components/Tooltip/Tooltip.tsx
index 8f3cb69b2..0b24596bf 100644
--- a/platform/ui-next/src/components/Tooltip/Tooltip.tsx
+++ b/platform/ui-next/src/components/Tooltip/Tooltip.tsx
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
- 'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
+ 'bg-primary-dark border-secondary-light text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-xs',
className
)}
{...props}
diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts
index 1306c1f74..c8951c2d7 100644
--- a/platform/ui-next/src/components/index.ts
+++ b/platform/ui-next/src/components/index.ts
@@ -6,15 +6,28 @@ import Combobox from './Combobox';
import Popover from './Popover';
import Calendar from './Calendar';
import DatePickerWithRange from './DateRange';
+import Separator from './Separator';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
+import { Toggle, toggleVariants } from './Toggle';
+import { ToggleGroup, ToggleGroupItem } from './ToggleGroup';
import { Input } from './Input';
import { Label } from './Label';
-import { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs';
-import { Separator } from './Separator';
import { Switch } from './Switch';
import { Checkbox } from './Checkbox';
-import { Toggle, toggleVariants } from './Toggle';
import { Slider } from './Slider';
import { ScrollArea, ScrollBar } from './ScrollArea';
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './Accordion';
+import { Icons } from './Icons';
+import SidePanel from './SidePanel';
+import StudyItem from './StudyItem';
+import StudyBrowser from './StudyBrowser';
+import StudyBrowserSort from './StudyBrowserSort';
+import StudyBrowserViewOptions from './StudyBrowserViewOptions';
+import Thumbnail from './Thumbnail';
+import ThumbnailList from './ThumbnailList';
+import PanelSection from './PanelSection';
+import DisplaySetMessageListTooltip from './DisplaySetMessageListTooltip';
+import { Toolbox, ToolboxUI } from './Toolbox';
export {
Button,
@@ -39,4 +52,23 @@ export {
toggleVariants,
Slider,
ScrollArea,
+ ToggleGroup,
+ ToggleGroupItem,
+ ScrollBar,
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Icons,
+ SidePanel,
+ StudyItem,
+ StudyBrowser,
+ StudyBrowserSort,
+ StudyBrowserViewOptions,
+ Thumbnail,
+ ThumbnailList,
+ PanelSection,
+ DisplaySetMessageListTooltip,
+ Toolbox,
+ ToolboxUI,
};
diff --git a/platform/ui-next/src/index.ts b/platform/ui-next/src/index.ts
index 0c140faba..3a3dfff9f 100644
--- a/platform/ui-next/src/index.ts
+++ b/platform/ui-next/src/index.ts
@@ -8,9 +8,34 @@ import {
Combobox,
Calendar,
DatePickerWithRange,
+ Separator,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+ Toggle,
+ toggleVariants,
+ ToggleGroup,
+ ToggleGroupItem,
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Icons,
+ SidePanel,
+ StudyItem,
+ StudyBrowser,
+ StudyBrowserSort,
+ StudyBrowserViewOptions,
+ Thumbnail,
+ ThumbnailList,
+ PanelSection,
+ DisplaySetMessageListTooltip,
+ Toolbox,
+ ToolboxUI,
} from './components';
+
import { useNotification, NotificationProvider } from './contextProviders';
-import { Toggle, toggleVariants } from './Toggle';
export {
// components
@@ -26,4 +51,29 @@ export {
// contextProviders
NotificationProvider,
useNotification,
+ Separator,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+ Toggle,
+ toggleVariants,
+ ToggleGroup,
+ ToggleGroupItem,
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Icons,
+ SidePanel,
+ StudyItem,
+ StudyBrowser,
+ StudyBrowserSort,
+ StudyBrowserViewOptions,
+ Thumbnail,
+ ThumbnailList,
+ PanelSection,
+ DisplaySetMessageListTooltip,
+ Toolbox,
+ ToolboxUI,
};
diff --git a/platform/ui-next/tailwind.config.js b/platform/ui-next/tailwind.config.js
index 54c59f431..e89be1e46 100644
--- a/platform/ui-next/tailwind.config.js
+++ b/platform/ui-next/tailwind.config.js
@@ -90,6 +90,20 @@ module.exports = {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
+ bkg: {
+ low: '#050615',
+ med: '#090C29',
+ full: '#041C4A',
+ },
+ info: {
+ primary: '#FFFFFF',
+ secondary: '#7BB2CE',
+ },
+ actions: {
+ primary: '#348CFD',
+ highlight: '#5ACCE6',
+ hover: 'rgba(52, 140, 253, 0.2)',
+ },
},
},
plugins: [require('tailwindcss-animate')],
diff --git a/platform/ui-next/tsconfig.json b/platform/ui-next/tsconfig.json
index e63e92ef4..adea64ef2 100644
--- a/platform/ui-next/tsconfig.json
+++ b/platform/ui-next/tsconfig.json
@@ -3,7 +3,9 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
- "@ui/*": ["./src/*"]
+ "@ui/*": ["./src/*"],
+ "@ohif/core": ["../core/src"],
+ "@ohif/ui": ["../ui/src"]
}
},
"include": ["src"],
diff --git a/platform/ui/tailwind.config.js b/platform/ui/tailwind.config.js
index 29e859d8f..ad6fb046e 100644
--- a/platform/ui/tailwind.config.js
+++ b/platform/ui/tailwind.config.js
@@ -59,7 +59,20 @@ module.exports = {
dark: '#726f7e',
active: '#2c3074',
},
-
+ bkg: {
+ low: '#050615',
+ med: '#090C29',
+ full: '#041C4A',
+ },
+ info: {
+ primary: '#FFFFFF',
+ secondary: '#7BB2CE',
+ },
+ actions: {
+ primary: '#348CFD',
+ highlight: '#5ACCE6',
+ hover: 'rgba(52, 140, 253, 0.2)',
+ },
customgreen: {
100: '#05D97C',
200: '#0FD97C',
diff --git a/yarn.lock b/yarn.lock
index ef1edd8e8..8bd793ce8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3527,9 +3527,9 @@
graceful-fs "4.2.10"
"@pnpm/npm-conf@^2.1.0":
- version "2.2.2"
- resolved "https://registry.yarnpkg.com/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz#0058baf1c26cbb63a828f0193795401684ac86f0"
- integrity sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz#bb375a571a0bd63ab0a23bece33033c683e9b6b0"
+ integrity sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==
dependencies:
"@pnpm/config.env-replace" "^1.1.0"
"@pnpm/network.ca-file" "^1.0.1"
@@ -3564,6 +3564,21 @@
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2"
integrity sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==
+"@radix-ui/react-accordion@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-accordion/-/react-accordion-1.2.0.tgz#aed0770fcb16285db992d81873ccd7a014c7f17d"
+ integrity sha512-HJOzSX8dQqtsp/3jVxCU3CXEONF7/2jlGAB28oX8TTw1Dz8JYbEI1UcL8355PuLBE41/IRRMvCw7VkiK/jcUOQ==
+ dependencies:
+ "@radix-ui/primitive" "1.1.0"
+ "@radix-ui/react-collapsible" "1.1.0"
+ "@radix-ui/react-collection" "1.1.0"
+ "@radix-ui/react-compose-refs" "1.1.0"
+ "@radix-ui/react-context" "1.1.0"
+ "@radix-ui/react-direction" "1.1.0"
+ "@radix-ui/react-id" "1.1.0"
+ "@radix-ui/react-primitive" "2.0.0"
+ "@radix-ui/react-use-controllable-state" "1.1.0"
+
"@radix-ui/react-arrow@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d"
@@ -3593,6 +3608,20 @@
"@radix-ui/react-use-previous" "1.1.0"
"@radix-ui/react-use-size" "1.1.0"
+"@radix-ui/react-collapsible@1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz#4d49ddcc7b7d38f6c82f1fd29674f6fab5353e77"
+ integrity sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==
+ dependencies:
+ "@radix-ui/primitive" "1.1.0"
+ "@radix-ui/react-compose-refs" "1.1.0"
+ "@radix-ui/react-context" "1.1.0"
+ "@radix-ui/react-id" "1.1.0"
+ "@radix-ui/react-presence" "1.1.0"
+ "@radix-ui/react-primitive" "2.0.0"
+ "@radix-ui/react-use-controllable-state" "1.1.0"
+ "@radix-ui/react-use-layout-effect" "1.1.0"
+
"@radix-ui/react-collection@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"