init
This commit is contained in:
155
platform/ui/src/components/AboutModal/AboutModal.tsx
Normal file
155
platform/ui/src/components/AboutModal/AboutModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import detect from 'browser-detect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import Icon from '../Icon';
|
||||
|
||||
const Link = ({ href, children, showIcon = false }) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
component="p"
|
||||
color="primaryActive"
|
||||
className="flex items-center"
|
||||
>
|
||||
{children}
|
||||
{!!showIcon && (
|
||||
<Icon
|
||||
name="external-link"
|
||||
className="ml-2 w-5 text-white"
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = ({ title, value, link }) => {
|
||||
return (
|
||||
<div className="mb-4 flex">
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
component="p"
|
||||
className="w-48 text-white"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{link ? (
|
||||
<Link href={link}>{value}</Link>
|
||||
) : (
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
component="p"
|
||||
className="w-48 text-white"
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AboutModal = ({ buildNumber, versionNumber, commitHash }) => {
|
||||
const { os, version, name } = detect();
|
||||
const browser = `${name[0].toUpperCase()}${name.substr(1)} ${version}`;
|
||||
const { t } = useTranslation('AboutModal');
|
||||
|
||||
const renderRowTitle = title => (
|
||||
<div className="mb-3 border-b-2 border-black pb-3">
|
||||
<Typography
|
||||
variant="inherit"
|
||||
color="primaryLight"
|
||||
className="text-[16px] font-semibold !leading-[1.2]"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{renderRowTitle(t('Important links'))}
|
||||
<div className="mb-8 flex">
|
||||
<Link
|
||||
href="https://community.ohif.org/"
|
||||
showIcon={true}
|
||||
>
|
||||
{t('Visit the forum')}
|
||||
</Link>
|
||||
<span className="ml-4">
|
||||
<Link
|
||||
href="https://github.com/OHIF/Viewers/issues/new/choose"
|
||||
showIcon={true}
|
||||
>
|
||||
{t('Report an issue')}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="ml-4">
|
||||
<Link
|
||||
href="https://ohif.org/"
|
||||
showIcon={true}
|
||||
>
|
||||
{t('More details')}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{renderRowTitle(t('Version information'))}
|
||||
<div className="flex flex-col">
|
||||
<Row
|
||||
title={t('Repository URL')}
|
||||
value="https://github.com/OHIF/Viewers/"
|
||||
link="https://github.com/OHIF/Viewers/"
|
||||
/>
|
||||
<Row
|
||||
title={t('Data citation')}
|
||||
value="https://github.com/OHIF/Viewers/blob/master/DATACITATION.md"
|
||||
link="https://github.com/OHIF/Viewers/blob/master/DATACITATION.md"
|
||||
/>
|
||||
{/* <Row
|
||||
title={t('Last master commits')}
|
||||
value="https://github.com/OHIF/Viewers/"
|
||||
link="https://github.com/OHIF/Viewers/"
|
||||
/> */}
|
||||
<Row
|
||||
title={t('Version number')}
|
||||
value={versionNumber}
|
||||
/>
|
||||
{buildNumber && (
|
||||
<Row
|
||||
title={t('Build number')}
|
||||
value={buildNumber}
|
||||
/>
|
||||
)}
|
||||
{commitHash && (
|
||||
<Row
|
||||
title={t('Commit hash')}
|
||||
value={commitHash}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
title={t('Browser')}
|
||||
value={browser}
|
||||
/>
|
||||
<Row
|
||||
title={t('OS')}
|
||||
value={os}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
AboutModal.propTypes = {
|
||||
buildNumber: PropTypes.string,
|
||||
versionNumber: PropTypes.string,
|
||||
};
|
||||
|
||||
export default AboutModal;
|
||||
@@ -0,0 +1,50 @@
|
||||
import AboutModal from '../../AboutModal';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import {
|
||||
createComponentTemplate,
|
||||
createStoryMetaSettings,
|
||||
} from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: AboutModal,
|
||||
title: 'Modals/About',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Modals/About"
|
||||
component={AboutModal}
|
||||
/>
|
||||
|
||||
export const aboutTemplate = args => (
|
||||
<div className="bg-primary-dark">
|
||||
<AboutModal {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="About Modal"
|
||||
componentRelativePath="AboutModal/AboutModal.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
OHIF about modal component provides information about the application version and build number
|
||||
|
||||
<Canvas>
|
||||
<Story name="Overview">{aboutTemplate.bind({})}</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={AboutModal} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="AboutModal/__stories__/aboutModal.stories.mdx" />
|
||||
2
platform/ui/src/components/AboutModal/index.js
Normal file
2
platform/ui/src/components/AboutModal/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import AboutModal from './AboutModal';
|
||||
export default AboutModal;
|
||||
36
platform/ui/src/components/ActionButtons/ActionButtons.tsx
Normal file
36
platform/ui/src/components/ActionButtons/ActionButtons.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, ButtonEnums } from '../../components';
|
||||
|
||||
function ActionButtons({ actions, disabled = false, t }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={disabled || action.disabled}
|
||||
type={ButtonEnums.type.secondary}
|
||||
size={ButtonEnums.size.small}
|
||||
className={index > 0 ? 'ml-2' : ''}
|
||||
>
|
||||
{t ? t(action.label) : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
ActionButtons.propTypes = {
|
||||
actions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
})
|
||||
).isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ActionButtons;
|
||||
3
platform/ui/src/components/ActionButtons/index.ts
Normal file
3
platform/ui/src/components/ActionButtons/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
export default ActionButtons;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
// see if any of the items are active from the outside
|
||||
const activeItem = items?.find(item => item.active);
|
||||
setActiveItemName(activeItem ? activeItem.name : null);
|
||||
}, [items]);
|
||||
|
||||
const activeItemOptions = items?.find(item => item.name === activeItemName)?.options;
|
||||
|
||||
return (
|
||||
<PanelSection
|
||||
title={title}
|
||||
childrenClassName="flex-shrink-0"
|
||||
>
|
||||
<div className="flex flex-col bg-black">
|
||||
<div className="bg-primary-dark mt-0.5 flex flex-wrap py-2">
|
||||
{items?.map(item => {
|
||||
return (
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
content={<span className="text-white">{item.name}</span>}
|
||||
key={item.name}
|
||||
>
|
||||
<div
|
||||
className="ml-2 mb-2"
|
||||
onClick={() => {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
setActiveItemName(item.name);
|
||||
item.onClick(item.name);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classnames(
|
||||
'text-primary-active grid h-[40px] w-[40px] place-items-center rounded-md bg-black ',
|
||||
activeItemName === item.name && 'bg-primary-light text-black',
|
||||
item.disabled && 'opacity-50',
|
||||
!item.disabled &&
|
||||
'hover:bg-primary-light cursor-pointer hover:cursor-pointer hover:text-black'
|
||||
)}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-primary-dark h-auto px-2">
|
||||
<ToolSettings options={activeItemOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
||||
|
||||
AdvancedToolbox.propTypes = {};
|
||||
|
||||
export default AdvancedToolbox;
|
||||
128
platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx
Normal file
128
platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { ButtonGroup, InputDoubleRange, InputRange } from '../../components';
|
||||
|
||||
const SETTING_TYPES = {
|
||||
RANGE: 'range',
|
||||
RADIO: 'radio',
|
||||
CUSTOM: 'custom',
|
||||
DOUBLE_RANGE: 'double-range',
|
||||
};
|
||||
|
||||
function ToolSettings({ options }) {
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof options === 'function') {
|
||||
return options();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 py-2 text-white">
|
||||
{options?.map(option => {
|
||||
if (option.condition && option.condition?.({ options }) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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
|
||||
className="border-secondary-light rounded-md border"
|
||||
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.value}
|
||||
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;
|
||||
@@ -0,0 +1,144 @@
|
||||
import AdvancedToolbox from '../../AdvancedToolbox';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import {
|
||||
createComponentTemplate,
|
||||
createStoryMetaSettings,
|
||||
} from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: AdvancedToolbox,
|
||||
title: 'Components/AdvancedToolbox',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/AdvancedToolbox"
|
||||
component={AdvancedToolbox}
|
||||
/>
|
||||
|
||||
export const advancedToolboxTemplate = args => (
|
||||
<div className="bg-primary-dark h-96 w-64">
|
||||
<AdvancedToolbox {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="Advanced Toolbox"
|
||||
componentRelativePath="AdvancedToolbox/AdvancedToolbox.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
OHIF advanced toolbox which can host set of tools that require more space for customization.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
title: 'Segmentation Tools',
|
||||
items: [
|
||||
{
|
||||
name: 'Brush',
|
||||
icon: 'icon-tool-brush',
|
||||
active: false,
|
||||
onClick: () => console.log('Brush clicked'),
|
||||
options: [
|
||||
{
|
||||
name: 'Radius (mm)',
|
||||
type: 'range',
|
||||
min: 1,
|
||||
max: 10,
|
||||
value: 5,
|
||||
step: 1,
|
||||
onChange: value => console.log('Brush size changed', value),
|
||||
},
|
||||
{
|
||||
name: 'Mode',
|
||||
type: 'radio',
|
||||
value: 'Circle',
|
||||
values: [
|
||||
{ value: 'Circle', label: 'Circle' },
|
||||
{ value: 'Sphere', label: 'Sphere' },
|
||||
{ value: 'Rectangle', label: 'Rectangle' },
|
||||
],
|
||||
onChange: value => console.log('Brush mode changed', value),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Eraser',
|
||||
icon: 'icon-tool-eraser',
|
||||
onClick: () => console.log('eraser clicked'),
|
||||
options: [
|
||||
{
|
||||
name: 'Mode',
|
||||
type: 'radio',
|
||||
value: 'EraserSphere',
|
||||
values: [
|
||||
{ value: 'EraserCircle', label: 'Circle' },
|
||||
{ value: 'EraserSphere', label: 'Sphere' },
|
||||
],
|
||||
onChange: value => console.log('Brush mode changed', value),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Threshold',
|
||||
icon: 'icon-tool-threshold',
|
||||
active: true,
|
||||
onClick: () => console.log('eraser clicked'),
|
||||
options: [
|
||||
{
|
||||
name: 'Radius (mm)',
|
||||
type: 'range',
|
||||
min: 1,
|
||||
max: 10,
|
||||
value: 5,
|
||||
step: 1,
|
||||
onChange: value => console.log('Brush size changed', value),
|
||||
},
|
||||
{
|
||||
name: 'Mode',
|
||||
type: 'radio',
|
||||
value: 'Circle',
|
||||
values: [
|
||||
{ value: 'Circle', label: 'Circle' },
|
||||
{ value: 'Sphere', label: 'Sphere' },
|
||||
{ value: 'Rectangle', label: 'Rectangle' },
|
||||
],
|
||||
onChange: value => console.log('Brush mode changed', value),
|
||||
},
|
||||
{
|
||||
name: 'custom',
|
||||
type: 'custom',
|
||||
children: () => {
|
||||
return (
|
||||
<div>
|
||||
<div>Custom</div>
|
||||
<input type="text" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{advancedToolboxTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={AdvancedToolbox} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="AdvancedToolbox/__stories__/AdvancedToolbox.stories.mdx" />
|
||||
4
platform/ui/src/components/AdvancedToolbox/index.js
Normal file
4
platform/ui/src/components/AdvancedToolbox/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import AdvancedToolbox from './AdvancedToolbox';
|
||||
import ToolSettings from './ToolSettings';
|
||||
export default AdvancedToolbox;
|
||||
export { ToolSettings };
|
||||
27
platform/ui/src/components/AllInOneMenu/BackItem.tsx
Normal file
27
platform/ui/src/components/AllInOneMenu/BackItem.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@ohif/ui';
|
||||
import DividerItem from './DividerItem';
|
||||
|
||||
type BackItemProps = {
|
||||
backLabel?: string;
|
||||
onBackClick: () => void;
|
||||
};
|
||||
|
||||
const BackItem = ({ backLabel, onBackClick }: BackItemProps) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="all-in-one-menu-item all-in-one-menu-item-effects"
|
||||
onClick={onBackClick}
|
||||
>
|
||||
<Icon name="content-prev"></Icon>
|
||||
|
||||
<div className="pl-2">{backLabel || 'Back to Display Options'}</div>
|
||||
</div>
|
||||
<DividerItem></DividerItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackItem;
|
||||
11
platform/ui/src/components/AllInOneMenu/DividerItem.tsx
Normal file
11
platform/ui/src/components/AllInOneMenu/DividerItem.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const DividerItem = () => {
|
||||
return (
|
||||
<div className="flex h-3.5 shrink-0 items-center px-2">
|
||||
<div className="bg-primary-dark h-[2px] w-full"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DividerItem;
|
||||
13
platform/ui/src/components/AllInOneMenu/HeaderItem.tsx
Normal file
13
platform/ui/src/components/AllInOneMenu/HeaderItem.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
type HeaderItemProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const HeaderItem = ({ children }: HeaderItemProps) => {
|
||||
return (
|
||||
<div className="text-aqua-pale mx-2 flex h-6 shrink-0 items-center text-[11px]">{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderItem;
|
||||
82
platform/ui/src/components/AllInOneMenu/IconMenu.tsx
Normal file
82
platform/ui/src/components/AllInOneMenu/IconMenu.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import OutsideClickHandler from 'react-outside-click-handler';
|
||||
import { MenuProps } from './Menu';
|
||||
import getIcon from '../Icon/getIcon';
|
||||
import classNames from 'classnames';
|
||||
import { AllInOneMenu } from '..';
|
||||
export interface IconMenuProps extends MenuProps {
|
||||
icon: string;
|
||||
iconClassName?: string;
|
||||
horizontalDirection?: AllInOneMenu.HorizontalDirection;
|
||||
verticalDirection?: AllInOneMenu.VerticalDirection;
|
||||
menuKey?: number | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An IconMenu allows for a div wrapped icon to be clicked to show and hide
|
||||
* an AllInOneMenu.Menu. Based on the direction(s) specified, the menu is
|
||||
* positioned relative to the icon.
|
||||
*
|
||||
* HorizontalDirection.LeftToRight - the left edges of the icon and menu are aligned
|
||||
* HorizontalDirection.RightRoLeft - the right edges of the icon and menu are aligned
|
||||
* VerticalDirection.TopToBottom - the top edge of the menu appears directly below the bottom edge of the icon
|
||||
* VerticalDirection.BottomToTop - the bottom edge of the menu appears directly above the top edge of the icon
|
||||
*
|
||||
* For example, if an IconMenu were situated in the bottom-left corner of a container,
|
||||
* it would be best to use BottomToTop and LeftToRight directions for it.
|
||||
*/
|
||||
export default function IconMenu({
|
||||
icon,
|
||||
iconClassName,
|
||||
horizontalDirection,
|
||||
verticalDirection,
|
||||
children,
|
||||
backLabel,
|
||||
menuClassName,
|
||||
menuStyle,
|
||||
onVisibilityChange,
|
||||
menuKey,
|
||||
}: IconMenuProps) {
|
||||
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
||||
|
||||
const toggleMenuVisibility = useCallback(() => setIsMenuVisible(isVisible => !isVisible), []);
|
||||
|
||||
return (
|
||||
<OutsideClickHandler
|
||||
onOutsideClick={toggleMenuVisibility}
|
||||
disabled={!isMenuVisible}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={iconClassName}
|
||||
onClick={toggleMenuVisibility}
|
||||
>
|
||||
{getIcon(icon)}
|
||||
</div>
|
||||
<AllInOneMenu.Menu
|
||||
key={menuKey}
|
||||
isVisible={isMenuVisible}
|
||||
backLabel={backLabel}
|
||||
menuClassName={classNames(
|
||||
menuClassName,
|
||||
'absolute',
|
||||
verticalDirection === AllInOneMenu.VerticalDirection.TopToBottom
|
||||
? 'top-[100%]'
|
||||
: 'bottom-[100%]',
|
||||
horizontalDirection === AllInOneMenu.HorizontalDirection.LeftToRight
|
||||
? 'left-0'
|
||||
: 'right-0'
|
||||
)}
|
||||
menuStyle={menuStyle}
|
||||
onVisibilityChange={isVis => {
|
||||
setIsMenuVisible(isVis);
|
||||
onVisibilityChange?.(isVis);
|
||||
}}
|
||||
horizontalDirection={horizontalDirection}
|
||||
>
|
||||
{children}
|
||||
</AllInOneMenu.Menu>
|
||||
</div>
|
||||
</OutsideClickHandler>
|
||||
);
|
||||
}
|
||||
45
platform/ui/src/components/AllInOneMenu/Item.tsx
Normal file
45
platform/ui/src/components/AllInOneMenu/Item.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { ReactNode, useCallback, useContext } from 'react';
|
||||
import { MenuContext } from './Menu';
|
||||
|
||||
type ItemProps = {
|
||||
label: string;
|
||||
secondaryLabel?: string;
|
||||
icon?: ReactNode;
|
||||
onClick?: () => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
rightIcon?: ReactNode;
|
||||
};
|
||||
|
||||
const Item = ({
|
||||
label,
|
||||
secondaryLabel,
|
||||
icon,
|
||||
rightIcon,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: ItemProps) => {
|
||||
const { hideMenu } = useContext(MenuContext);
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
hideMenu();
|
||||
onClick?.();
|
||||
}, [hideMenu, onClick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="all-in-one-menu-item all-in-one-menu-item-effects"
|
||||
onClick={onClickHandler}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{icon && <div className="pr-2">{icon}</div>}
|
||||
<span>{label}</span>
|
||||
{secondaryLabel != null && <span className="text-aqua-pale ml-[1ch]">{secondaryLabel}</span>}
|
||||
{rightIcon && <div className="ml-auto">{rightIcon}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Item;
|
||||
29
platform/ui/src/components/AllInOneMenu/ItemPanel.tsx
Normal file
29
platform/ui/src/components/AllInOneMenu/ItemPanel.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { ReactNode, useContext, useEffect } from 'react';
|
||||
import { MenuContext } from './Menu';
|
||||
|
||||
type ItemPanelProps = {
|
||||
label?: string;
|
||||
index?: number;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ItemPanel = ({ label, index = 0, children }: ItemPanelProps) => {
|
||||
const { addItemPanel, activePanelIndex } = useContext(MenuContext);
|
||||
|
||||
useEffect(() => {
|
||||
addItemPanel(index, label);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
activePanelIndex === index && (
|
||||
<div
|
||||
style={{ scrollbarGutter: 'auto' }}
|
||||
className="ohif-scrollbar flex flex-col overflow-auto"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemPanel;
|
||||
188
platform/ui/src/components/AllInOneMenu/Menu.tsx
Normal file
188
platform/ui/src/components/AllInOneMenu/Menu.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { createContext, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import './allInOneMenu.css';
|
||||
import DividerItem from './DividerItem';
|
||||
import PanelSelector from './PanelSelector';
|
||||
import classNames from 'classnames';
|
||||
import BackItem from './BackItem';
|
||||
|
||||
/**
|
||||
* The vertical direction that the menu will be opened/used with.
|
||||
*
|
||||
* A TopToBottom menu would be used for cases where the menu is opened "near"
|
||||
* the top edge of its container. Likewise a BottomToTop menu would be used
|
||||
* for cases where the menu is opened "near" the bottom edge of its container.
|
||||
*
|
||||
* See IconMenu for more information.
|
||||
*/
|
||||
export enum VerticalDirection {
|
||||
TopToBottom,
|
||||
BottomToTop,
|
||||
}
|
||||
|
||||
/**
|
||||
* The horizontal direction that the menu is opened/used with.
|
||||
* This direction dictates the general direction sub-menus and
|
||||
* back-to-menus are opened with. For example, a RightToLeft menu
|
||||
* will have sub-menu items indicated with a left pointing chevron
|
||||
* and aligned with the left edge of the menu. Similarly back-to items of a
|
||||
* RightToLeft menu are indicated with a right pointing chevron and
|
||||
* aligned with the right edge of the menu.
|
||||
*
|
||||
* It is also worth noting that a LeftToRight menu would be used for
|
||||
* cases where a menu is opened "near" the left edge of its container.
|
||||
* Likewise, a RightToLeft menu would be used for cases where a menu is opened
|
||||
* "near" the right edge of its container.
|
||||
*
|
||||
* See IconMenu for more information.
|
||||
*/
|
||||
export enum HorizontalDirection {
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
export interface MenuProps {
|
||||
menuStyle?: unknown;
|
||||
menuClassName?: string;
|
||||
isVisible?: boolean;
|
||||
preventHideMenu?: boolean;
|
||||
backLabel?: string;
|
||||
headerComponent?: ReactNode;
|
||||
showHeaderDivider?: boolean;
|
||||
activePanelIndex?: number;
|
||||
onVisibilityChange?: (isVisible: boolean) => void;
|
||||
horizontalDirection?: HorizontalDirection;
|
||||
children: ReactNode;
|
||||
}
|
||||
type MenuContextProps = {
|
||||
showSubMenu: (subMenuProps: MenuProps) => void;
|
||||
hideMenu: () => void;
|
||||
addItemPanel: (index: number, label: string) => void;
|
||||
horizontalDirection: HorizontalDirection;
|
||||
activePanelIndex: number;
|
||||
};
|
||||
|
||||
type MenuPathState = {
|
||||
props: MenuProps;
|
||||
activePanelIndex: number;
|
||||
};
|
||||
|
||||
export const MenuContext = createContext<MenuContextProps>(null);
|
||||
|
||||
const Menu = (props: MenuProps) => {
|
||||
const {
|
||||
isVisible,
|
||||
onVisibilityChange,
|
||||
activePanelIndex,
|
||||
preventHideMenu,
|
||||
menuClassName,
|
||||
menuStyle,
|
||||
horizontalDirection = HorizontalDirection.LeftToRight,
|
||||
} = props;
|
||||
|
||||
const [isMenuVisible, setIsMenuVisible] = useState(isVisible);
|
||||
|
||||
// The menuPath is an array consisting of this top Menu and every SubMenu
|
||||
// that has been traversed/opened by the user with the last item in the array
|
||||
// being the current (sub)menu that is currently visible. This allows for the previously
|
||||
// viewed menus to be returned to via the Back button at the top of the menu.
|
||||
const [menuPath, setMenuPath] = useState<Array<MenuPathState>>([
|
||||
{ props, activePanelIndex: activePanelIndex || 0 },
|
||||
]);
|
||||
const [itemPanelLabels, setItemPanelLabels] = useState<Array<string>>([]);
|
||||
|
||||
const hideMenu = useCallback(() => {
|
||||
if (preventHideMenu) {
|
||||
return;
|
||||
}
|
||||
setMenuPath(path => [path[0]]);
|
||||
setItemPanelLabels([]);
|
||||
setIsMenuVisible(false);
|
||||
onVisibilityChange?.(false);
|
||||
}, [preventHideMenu, onVisibilityChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
setIsMenuVisible(isVisible);
|
||||
onVisibilityChange?.(isVisible);
|
||||
} else {
|
||||
hideMenu();
|
||||
}
|
||||
}, [hideMenu, isVisible, onVisibilityChange]);
|
||||
|
||||
const showSubMenu = useCallback((subMenuProps: MenuProps) => {
|
||||
setMenuPath(path => {
|
||||
return [
|
||||
...path,
|
||||
{ props: subMenuProps, activePanelIndex: subMenuProps.activePanelIndex || 0 },
|
||||
];
|
||||
});
|
||||
setItemPanelLabels([]);
|
||||
}, []);
|
||||
|
||||
const addItemPanel = useCallback((index, label) => {
|
||||
setItemPanelLabels(labels => {
|
||||
return [...labels.slice(0, index), label, ...labels.slice(index + 1, labels.length)];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onActivePanelIndexChange = useCallback(index => {
|
||||
setMenuPath(path => {
|
||||
return [
|
||||
...path.slice(0, path.length - 1),
|
||||
{ ...path[path.length - 1], activePanelIndex: index },
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onBackClick = useCallback(() => {
|
||||
setMenuPath(path => [...path.slice(0, path.length - 1)]);
|
||||
setItemPanelLabels([]);
|
||||
}, []);
|
||||
|
||||
const { props: currentMenuProps, activePanelIndex: currentMenuActivePanelIndex } =
|
||||
menuPath[menuPath.length - 1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuContext.Provider
|
||||
value={{
|
||||
showSubMenu,
|
||||
hideMenu,
|
||||
addItemPanel,
|
||||
activePanelIndex: currentMenuActivePanelIndex,
|
||||
horizontalDirection,
|
||||
}}
|
||||
>
|
||||
{isMenuVisible && (
|
||||
<div
|
||||
className={classNames(
|
||||
'bg-secondary-dark flex select-none flex-col rounded px-1 py-1.5 text-white opacity-90',
|
||||
menuClassName
|
||||
)}
|
||||
style={menuStyle}
|
||||
>
|
||||
{menuPath.length > 1 && (
|
||||
<BackItem
|
||||
backLabel={menuPath[menuPath.length - 2].props.backLabel}
|
||||
onBackClick={onBackClick}
|
||||
/>
|
||||
)}
|
||||
{itemPanelLabels.length > 1 && (
|
||||
<PanelSelector
|
||||
panelLabels={itemPanelLabels}
|
||||
activeIndex={currentMenuActivePanelIndex}
|
||||
onActiveIndexChange={onActivePanelIndexChange}
|
||||
></PanelSelector>
|
||||
)}
|
||||
{currentMenuProps.headerComponent}
|
||||
{currentMenuProps.showHeaderDivider && <DividerItem />}
|
||||
{currentMenuProps.children}
|
||||
</div>
|
||||
)}
|
||||
</MenuContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
31
platform/ui/src/components/AllInOneMenu/PanelSelector.tsx
Normal file
31
platform/ui/src/components/AllInOneMenu/PanelSelector.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ButtonGroup } from '../../components';
|
||||
|
||||
type PanelSelectorProps = {
|
||||
panelLabels: Array<ReactNode>;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
activeIndex: number;
|
||||
};
|
||||
|
||||
const PanelSelector = ({ panelLabels, onActiveIndexChange, activeIndex }: PanelSelectorProps) => {
|
||||
const getButtons = () => {
|
||||
return panelLabels.map((panelLabel, index) => {
|
||||
return {
|
||||
children: panelLabel,
|
||||
key: index,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-2 my-1 flex justify-center">
|
||||
<ButtonGroup
|
||||
buttons={getButtons()}
|
||||
onActiveIndexChange={onActiveIndexChange}
|
||||
defaultActiveIndex={activeIndex}
|
||||
></ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PanelSelector;
|
||||
38
platform/ui/src/components/AllInOneMenu/SubMenu.tsx
Normal file
38
platform/ui/src/components/AllInOneMenu/SubMenu.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { MenuContext, MenuProps } from './Menu';
|
||||
import Icon from '../Icon';
|
||||
|
||||
export interface SubMenuProps extends MenuProps {
|
||||
itemLabel: string;
|
||||
onClick?: () => void;
|
||||
itemIcon?: string;
|
||||
}
|
||||
|
||||
const SubMenu = (props: SubMenuProps) => {
|
||||
const { showSubMenu } = useContext(MenuContext);
|
||||
|
||||
const onClickHandler = useCallback(() => {
|
||||
showSubMenu(props);
|
||||
props.onClick?.();
|
||||
}, [showSubMenu, props]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="all-in-one-menu-item all-in-one-menu-item-effects flex items-center"
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
{props.itemIcon && (
|
||||
<Icon
|
||||
name={props.itemIcon}
|
||||
width="25px"
|
||||
height="25px"
|
||||
className="mr-2"
|
||||
></Icon>
|
||||
)}
|
||||
<div className="mr-auto">{props.itemLabel}</div>
|
||||
<Icon name="content-next"></Icon>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubMenu;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { DividerItem, HeaderItem, Item, ItemPanel, Menu, SubMenu } from '..';
|
||||
import InputRange from '../../InputRange/index.js';
|
||||
import SwitchButton from '../../SwitchButton/index.js';
|
||||
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: Menu,
|
||||
title: 'Components/AllInOneMenu',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/AllInOneMenu"
|
||||
component={Menu}
|
||||
/>
|
||||
|
||||
export const AllInOneMenuTemplate = args => (
|
||||
<div className="w-80">
|
||||
<Menu {...args}>
|
||||
<ItemPanel>
|
||||
<Item
|
||||
label="Item 1"
|
||||
key="0"
|
||||
onClick={() => console.info('Item 1 clicked.')}
|
||||
></Item>
|
||||
<Item
|
||||
label="Item 2"
|
||||
secondaryLabel="Alt item 2"
|
||||
key="1"
|
||||
></Item>
|
||||
<div
|
||||
className="all-in-one-menu-item py-1"
|
||||
style={{ flexBasis: 'content' }}
|
||||
>
|
||||
<div>Arbitrary item component:</div>
|
||||
<InputRange
|
||||
minValue={1}
|
||||
maxValue={10}
|
||||
value={5}
|
||||
onChange={() => {}}
|
||||
></InputRange>
|
||||
</div>
|
||||
<DividerItem key="2"></DividerItem>
|
||||
<SubMenu
|
||||
key="3"
|
||||
backLabel="Back to Level 2"
|
||||
itemLabel="Item 3 opens a sub menu"
|
||||
onClick={() => console.info('Sub menu item clicked.')}
|
||||
showHeaderDivider={true}
|
||||
headerComponent={
|
||||
<div className="all-in-one-menu-item flex w-full justify-center">
|
||||
<SwitchButton label="Switch item in header" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ItemPanel
|
||||
index={0}
|
||||
label="Panel A"
|
||||
>
|
||||
<Item label="Panel A item"></Item>
|
||||
</ItemPanel>
|
||||
<ItemPanel
|
||||
index={1}
|
||||
label="Panel B"
|
||||
>
|
||||
<Item label="Panel B item"></Item>
|
||||
<SubMenu
|
||||
itemLabel="Opens a sub, sub menu"
|
||||
headerComponent={<HeaderItem>Header for scrolling list of items</HeaderItem>}
|
||||
>
|
||||
<ItemPanel label="Sub sub Panel">
|
||||
<Item label="Sub sub menu item 1"></Item>
|
||||
<Item label="Sub sub menu item 2"></Item>
|
||||
<Item label="Sub sub menu item 3"></Item>
|
||||
<Item label="Sub sub menu item 4"></Item>
|
||||
<Item label="Sub sub menu item 5"></Item>
|
||||
<Item label="Sub sub menu item 6"></Item>
|
||||
</ItemPanel>
|
||||
</SubMenu>
|
||||
</ItemPanel>
|
||||
</SubMenu>
|
||||
</ItemPanel>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="AllInOneMenu"
|
||||
componentRelativePath="AllInOneMenu/AllInOneMenu.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
AllInOneMenu is a component that renders a menu with various menu items, sub-menus and
|
||||
sub-components. The particular feature of the AllInOneMenu is its ability to render a sub-menu by
|
||||
replacing the parent menu on screen. It also provides the ability to return to the parent menu with
|
||||
a back menu item from the sub-menu. Furthermore, each menu level can be split into item panes - that
|
||||
is several panes of menu items that are switched in and out of view like a tabbed pane.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
className: 'max-h-[210px]',
|
||||
isVisible: true,
|
||||
preventHideMenu: true,
|
||||
backLabel: 'Back to Level 1',
|
||||
onVisibilityChange: (isVisible) => console.info(`The menu visibility: ${isVisible}`),
|
||||
}}
|
||||
>
|
||||
|
||||
{AllInOneMenuTemplate.bind({})}
|
||||
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Menu} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="AllInOneMenu/__stories__/AllInOneMenu.stories.mdx" />
|
||||
11
platform/ui/src/components/AllInOneMenu/allInOneMenu.css
Normal file
11
platform/ui/src/components/AllInOneMenu/allInOneMenu.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.all-in-one-menu-item {
|
||||
@apply h-8 px-2 text-[14px] w-full;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.all-in-one-menu-item-effects {
|
||||
@apply cursor-pointer hover:bg-primary-dark hover:rounded;
|
||||
}
|
||||
19
platform/ui/src/components/AllInOneMenu/index.tsx
Normal file
19
platform/ui/src/components/AllInOneMenu/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Menu, { HorizontalDirection, VerticalDirection } from './Menu';
|
||||
import DividerItem from './DividerItem';
|
||||
import HeaderItem from './HeaderItem';
|
||||
import IconMenu from './IconMenu';
|
||||
import Item from './Item';
|
||||
import ItemPanel from './ItemPanel';
|
||||
import SubMenu from './SubMenu';
|
||||
|
||||
export {
|
||||
Menu,
|
||||
DividerItem,
|
||||
HeaderItem,
|
||||
IconMenu,
|
||||
Item,
|
||||
ItemPanel,
|
||||
SubMenu,
|
||||
HorizontalDirection,
|
||||
VerticalDirection,
|
||||
};
|
||||
150
platform/ui/src/components/Button/Button.tsx
Normal file
150
platform/ui/src/components/Button/Button.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import * as ButtonEnums from './ButtonEnums';
|
||||
import Tooltip from '../Tooltip/Tooltip';
|
||||
|
||||
const sizeClasses = {
|
||||
[ButtonEnums.size.small]: 'h-[26px] text-[13px]',
|
||||
[ButtonEnums.size.medium]: 'h-[32px] text-[14px]',
|
||||
};
|
||||
|
||||
const layoutClasses =
|
||||
'box-content inline-flex flex-row items-center justify-center gap-[5px] justify center px-[10px] outline-none rounded';
|
||||
|
||||
const baseFontTextClasses = 'leading-[1.2] font-sans text-center whitespace-nowrap';
|
||||
|
||||
const fontTextClasses = {
|
||||
[ButtonEnums.type.primary]: classnames(baseFontTextClasses, 'font-semibold'),
|
||||
[ButtonEnums.type.secondary]: classnames(baseFontTextClasses, 'font-400'),
|
||||
};
|
||||
|
||||
const baseEnabledEffectClasses = 'transition duration-300 ease-in-out focus:outline-none';
|
||||
|
||||
const enabledEffectClasses = {
|
||||
[ButtonEnums.type.primary]: classnames(
|
||||
baseEnabledEffectClasses,
|
||||
'hover:bg-customblue-80 active:bg-customblue-40'
|
||||
),
|
||||
[ButtonEnums.type.secondary]: classnames(
|
||||
baseEnabledEffectClasses,
|
||||
'hover:bg-customblue-50 active:bg-customblue-20'
|
||||
),
|
||||
};
|
||||
|
||||
const baseEnabledClasses = 'text-white';
|
||||
|
||||
const enabledClasses = {
|
||||
[ButtonEnums.type.primary]: classnames(
|
||||
'bg-primary-main',
|
||||
baseEnabledClasses,
|
||||
enabledEffectClasses[ButtonEnums.type.primary]
|
||||
),
|
||||
[ButtonEnums.type.secondary]: classnames(
|
||||
'bg-customblue-30',
|
||||
baseEnabledClasses,
|
||||
enabledEffectClasses[ButtonEnums.type.secondary]
|
||||
),
|
||||
};
|
||||
|
||||
const disabledClasses = 'bg-inputfield-placeholder text-common-light cursor-default';
|
||||
|
||||
const defaults = {
|
||||
color: 'default',
|
||||
disabled: false,
|
||||
rounded: 'small',
|
||||
size: ButtonEnums.size.medium,
|
||||
type: ButtonEnums.type.primary,
|
||||
};
|
||||
|
||||
const Button = ({
|
||||
children = '',
|
||||
size = defaults.size,
|
||||
disabled = defaults.disabled,
|
||||
type = defaults.type,
|
||||
startIcon: startIconProp,
|
||||
endIcon: endIconProp,
|
||||
name,
|
||||
className,
|
||||
onClick = () => {},
|
||||
dataCY,
|
||||
startIconTooltip = null,
|
||||
endIconTooltip = null,
|
||||
}) => {
|
||||
dataCY = dataCY || `${name}-btn`;
|
||||
|
||||
const startIcon = startIconProp && (
|
||||
<>
|
||||
{React.cloneElement(startIconProp, {
|
||||
className: classnames('w-4 h-4 fill-current', startIconProp?.props?.className),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
const endIcon = endIconProp && (
|
||||
<>
|
||||
{React.cloneElement(endIconProp, {
|
||||
className: classnames('w-4 h-4 fill-current', endIconProp?.props?.className),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
const buttonElement = useRef(null);
|
||||
|
||||
const handleOnClick = e => {
|
||||
buttonElement.current.blur();
|
||||
if (!disabled) {
|
||||
onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
const finalClassName = classnames(
|
||||
layoutClasses,
|
||||
fontTextClasses[type],
|
||||
disabled ? disabledClasses : enabledClasses[type],
|
||||
sizeClasses[size],
|
||||
children ? 'min-w-[32px]' : '', // minimum width for buttons with text; icon only button does NOT get a minimum width
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={finalClassName}
|
||||
disabled={disabled}
|
||||
ref={buttonElement}
|
||||
onClick={handleOnClick}
|
||||
data-cy={dataCY}
|
||||
>
|
||||
{startIconTooltip ? <Tooltip content={startIconTooltip}>{startIcon}</Tooltip> : startIcon}
|
||||
{children}
|
||||
{endIconTooltip ? <Tooltip content={endIconTooltip}>{endIcon}</Tooltip> : endIcon}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
Button.propTypes = {
|
||||
/** What is inside the button, can be text or react component */
|
||||
children: PropTypes.node,
|
||||
/** Callback to be called when the button is clicked */
|
||||
onClick: PropTypes.func.isRequired,
|
||||
/** Button size */
|
||||
size: PropTypes.oneOf([ButtonEnums.size.medium, ButtonEnums.size.small]),
|
||||
/** Whether the button should be disabled */
|
||||
disabled: PropTypes.bool,
|
||||
/** Button type */
|
||||
type: PropTypes.oneOf([ButtonEnums.type.primary, ButtonEnums.type.secondary]),
|
||||
name: PropTypes.string,
|
||||
/** Button start icon name - if any icon is specified */
|
||||
startIcon: PropTypes.node,
|
||||
/** Button end icon name - if any icon is specified */
|
||||
endIcon: PropTypes.node,
|
||||
/** Additional TailwindCSS classnames */
|
||||
className: PropTypes.string,
|
||||
/** Tooltip for the start icon */
|
||||
startIconTooltip: PropTypes.node,
|
||||
/** Tooltip for the end icon */
|
||||
endIconTooltip: PropTypes.node,
|
||||
/** Data attribute for testing */
|
||||
dataCY: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
15
platform/ui/src/components/Button/ButtonEnums.ts
Normal file
15
platform/ui/src/components/Button/ButtonEnums.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
enum type {
|
||||
primary = 'primary',
|
||||
secondary = 'secondary',
|
||||
}
|
||||
enum size {
|
||||
medium = 'medium',
|
||||
small = 'small',
|
||||
}
|
||||
|
||||
enum orientation {
|
||||
horizontal = 'horizontal',
|
||||
vertical = 'vertical',
|
||||
}
|
||||
|
||||
export { type, size, orientation };
|
||||
171
platform/ui/src/components/Button/__stories__/button.stories.mdx
Normal file
171
platform/ui/src/components/Button/__stories__/button.stories.mdx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Button, ButtonEnums } from '../../../components';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import {
|
||||
createComponentTemplate,
|
||||
createStoryMetaSettings,
|
||||
} from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Button,
|
||||
title: 'Components/Button',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/Button"
|
||||
component={Button}
|
||||
/>
|
||||
|
||||
export const buttonTemplate = createComponentTemplate(Button);
|
||||
|
||||
<Heading
|
||||
title="Button"
|
||||
componentRelativePath="Button/Button.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
You can use the button component to create a button. It can be used in different ways, the default
|
||||
button is a simple button with text.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{ children: 'Button', color: 'default' }}
|
||||
>
|
||||
{buttonTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Button} />
|
||||
|
||||
## Usage
|
||||
|
||||
### Types
|
||||
|
||||
There can be different types of buttons: `primary`, and `secondary`.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Types">
|
||||
<div className="flex space-x-2">
|
||||
<Button type={ButtonEnums.type.primary}>Primary Button</Button>
|
||||
<Button type={ButtonEnums.type.secondary}>Secondary Button</Button>
|
||||
</div>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Sizes
|
||||
|
||||
There are different sizes for the button: `small`, and `medium`. The size refers to the button's
|
||||
height.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Sizes">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button size={ButtonEnums.size.small}>Small Button</Button>
|
||||
<Button size={ButtonEnums.size.medium}>Medium Button</Button>
|
||||
</div>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Mixing props
|
||||
|
||||
You can mix different props together to create a button.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Custom">
|
||||
<Button
|
||||
type={ButtonEnums.type.secondary}
|
||||
size={ButtonEnums.size.small}
|
||||
>
|
||||
Small, Secondary Button
|
||||
</Button>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Disabled
|
||||
|
||||
You can disable the button by setting the `disabled` property to true.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Disabled">
|
||||
<Button disabled={true}>Disabled Button</Button>
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Start/End Icons
|
||||
|
||||
You can add an icon to the start of the button. It accepts an icon component.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Start Icon">
|
||||
{() => {
|
||||
// svg icon for github
|
||||
const Github = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="feather feather-github"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
return <Button startIcon={<Github />}>Start Icon Button</Button>;
|
||||
}}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
End Icon is the same as start icon, but for the end of the button.
|
||||
|
||||
<Canvas>
|
||||
<Story name="End Icon">
|
||||
{() => {
|
||||
// svg icon for github
|
||||
const Github = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="feather feather-github"
|
||||
>
|
||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
startIcon={<Github />}
|
||||
endIcon={<Github />}
|
||||
>
|
||||
Start and End Icon Button
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Button/__stories__/button.stories.mdx" />
|
||||
5
platform/ui/src/components/Button/index.js
Normal file
5
platform/ui/src/components/Button/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import Button from './Button';
|
||||
import * as ButtonEnums from './ButtonEnums';
|
||||
|
||||
export { ButtonEnums };
|
||||
export default Button;
|
||||
105
platform/ui/src/components/ButtonGroup/ButtonGroup.tsx
Normal file
105
platform/ui/src/components/ButtonGroup/ButtonGroup.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState, useEffect, cloneElement, Children } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { ButtonEnums } from '../../components';
|
||||
|
||||
const ButtonGroup = ({
|
||||
children,
|
||||
className,
|
||||
orientation = ButtonEnums.orientation.horizontal,
|
||||
activeIndex: defaultActiveIndex = 0,
|
||||
onActiveIndexChange,
|
||||
separated = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(defaultActiveIndex);
|
||||
}, [defaultActiveIndex]);
|
||||
|
||||
const handleButtonClick = index => {
|
||||
setActiveIndex(index);
|
||||
onActiveIndexChange && onActiveIndexChange(index);
|
||||
};
|
||||
|
||||
const orientationClasses = {
|
||||
horizontal: 'flex-row',
|
||||
vertical: 'flex-col',
|
||||
};
|
||||
|
||||
const wrapperClasses = classnames(
|
||||
`${separated ? '' : 'inline-flex'}`,
|
||||
orientationClasses[orientation],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(wrapperClasses, ' text-[13px]', {
|
||||
' rounded-md bg-black': !separated,
|
||||
})}
|
||||
>
|
||||
{!separated && (
|
||||
<div className="flex h-[32px] w-full">
|
||||
{Children.map(children, (child, index) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return cloneElement(child, {
|
||||
key: index,
|
||||
className: classnames(
|
||||
'rounded-[4px] px-2 py-1',
|
||||
index === activeIndex
|
||||
? 'bg-customblue-40 text-white'
|
||||
: 'text-primary-active bg-black',
|
||||
child.props.className,
|
||||
child.props.disabled ? 'ohif-disabled' : ''
|
||||
),
|
||||
onClick: e => {
|
||||
child.props.onClick && child.props.onClick(e);
|
||||
handleButtonClick(index);
|
||||
},
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{separated && (
|
||||
<div className="flex space-x-2">
|
||||
{Children.map(children, (child, index) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return cloneElement(child, {
|
||||
key: index,
|
||||
className: classnames(
|
||||
'rounded-[4px] px-2 py-1',
|
||||
index === activeIndex
|
||||
? 'bg-customblue-40 text-white'
|
||||
: 'text-primary-active bg-black border-secondary-light rounded-[5px] border',
|
||||
child.props.className,
|
||||
child.props.disabled ? 'ohif-disabled' : ''
|
||||
),
|
||||
onClick: e => {
|
||||
child.props.onClick && child.props.onClick(e);
|
||||
handleButtonClick(index);
|
||||
},
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ButtonGroup.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
orientation: PropTypes.oneOf(Object.values(ButtonEnums.orientation)),
|
||||
activeIndex: PropTypes.number,
|
||||
onActiveIndexChange: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
separated: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ButtonGroup;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button, ButtonGroup, ButtonEnums } from '../../../components';
|
||||
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import {
|
||||
createComponentTemplate,
|
||||
createStoryMetaSettings,
|
||||
} from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: ButtonGroup,
|
||||
title: 'Components/ButtonGroup',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/ButtonGroup"
|
||||
component={ButtonGroup}
|
||||
/>
|
||||
|
||||
<Heading
|
||||
title="ButtonGroup"
|
||||
componentRelativePath="ButtonGroup/ButtonGroup.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
ButtonGroup is a container for a group of buttons.
|
||||
|
||||
<Canvas>
|
||||
<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>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={ButtonGroup} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="ButtonGroup/__stories__/buttonGroup.stories.mdx" />
|
||||
2
platform/ui/src/components/ButtonGroup/index.js
Normal file
2
platform/ui/src/components/ButtonGroup/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import ButtonGroup from './ButtonGroup';
|
||||
export default ButtonGroup;
|
||||
55
platform/ui/src/components/CheckBox/CheckBox.tsx
Normal file
55
platform/ui/src/components/CheckBox/CheckBox.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon, Typography } from '../../';
|
||||
|
||||
/**
|
||||
* REACT CheckBox component
|
||||
* it has two props, checked and onChange
|
||||
* checked is a boolean value
|
||||
* onChange is a function that will be called when the checkbox is clicked
|
||||
*
|
||||
* CheckBox is a component that allows you to use as a boolean
|
||||
*/
|
||||
|
||||
const CheckBox: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (state) => void;
|
||||
className?: string;
|
||||
label: string;
|
||||
labelClassName?: string;
|
||||
labelVariant?: string;
|
||||
}> = ({ checked, onChange, label, labelClassName, labelVariant = 'body', className }) => {
|
||||
const [isChecked, setIsChecked] = useState(checked);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setIsChecked(!isChecked);
|
||||
onChange(!isChecked);
|
||||
}, [isChecked, onChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex cursor-pointer items-center space-x-1 ${className ? className : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isChecked ? <Icon name="checkbox-checked" /> : <Icon name="checkbox-unchecked" />}
|
||||
|
||||
<Typography
|
||||
variant={labelVariant ?? 'subtitle'}
|
||||
component="p"
|
||||
className={labelClassName ?? 'text-white '}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CheckBox.propTypes = {
|
||||
checked: PropTypes.bool,
|
||||
onChange: PropTypes.func,
|
||||
label: PropTypes.string,
|
||||
labelClassName: PropTypes.string,
|
||||
labelVariant: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CheckBox;
|
||||
@@ -0,0 +1,49 @@
|
||||
import CheckBox from '../CheckBox';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: CheckBox,
|
||||
title: 'Components/CheckBox',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/CheckBox"
|
||||
component={CheckBox}
|
||||
/>
|
||||
|
||||
export const CheckBoxTemplate = args => <CheckBox {...args} />;
|
||||
|
||||
<Heading
|
||||
title="CheckBox"
|
||||
componentRelativePath="CheckBox/CheckBox.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
CheckBox is a component that allows you to use as a boolean value
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
checked: false,
|
||||
onChange: () => console.log('on change callback'),
|
||||
label: 'checkBoxLabel',
|
||||
labelClassName: 'text-black text-sm',
|
||||
}}
|
||||
>
|
||||
{CheckBoxTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={CheckBox} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="CheckBox/__stories__/CheckBox.stories.mdx" />
|
||||
3
platform/ui/src/components/CheckBox/index.js
Normal file
3
platform/ui/src/components/CheckBox/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import CheckBox from './CheckBox';
|
||||
|
||||
export default CheckBox;
|
||||
3
platform/ui/src/components/CinePlayer/CinePlayer.css
Normal file
3
platform/ui/src/components/CinePlayer/CinePlayer.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.cine-fps-range-tooltip .tooltip.tooltip-top {
|
||||
bottom: 85% !important;
|
||||
}
|
||||
191
platform/ui/src/components/CinePlayer/CinePlayer.tsx
Normal file
191
platform/ui/src/components/CinePlayer/CinePlayer.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
import Icon from '../Icon';
|
||||
import Tooltip from '../Tooltip';
|
||||
import InputRange from '../InputRange';
|
||||
|
||||
import './CinePlayer.css';
|
||||
|
||||
export type CinePlayerProps = {
|
||||
className: string;
|
||||
isPlaying: boolean;
|
||||
minFrameRate?: number;
|
||||
maxFrameRate?: number;
|
||||
stepFrameRate?: number;
|
||||
frameRate?: number;
|
||||
onFrameRateChange: (value: number) => void;
|
||||
onPlayPauseChange: (value: boolean) => void;
|
||||
onClose: () => void;
|
||||
updateDynamicInfo?: () => void;
|
||||
dynamicInfo?: {
|
||||
timePointIndex: number;
|
||||
numTimePoints: number;
|
||||
label?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const fpsButtonClassNames =
|
||||
'cursor-pointer text-primary-active active:text-primary-light hover:bg-customblue-300 w-4 flex items-center justify-center';
|
||||
|
||||
const CinePlayer: React.FC<CinePlayerProps> = ({
|
||||
className,
|
||||
isPlaying = false,
|
||||
minFrameRate = 1,
|
||||
maxFrameRate = 90,
|
||||
stepFrameRate = 1,
|
||||
frameRate: defaultFrameRate = 24,
|
||||
onFrameRateChange = () => {},
|
||||
onPlayPauseChange = () => {},
|
||||
onClose = () => {},
|
||||
dynamicInfo = {},
|
||||
updateDynamicInfo,
|
||||
}) => {
|
||||
const isDynamic = !!dynamicInfo?.numTimePoints;
|
||||
const [frameRate, setFrameRate] = useState(defaultFrameRate);
|
||||
const debouncedSetFrameRate = useCallback(debounce(onFrameRateChange, 100), [onFrameRateChange]);
|
||||
|
||||
const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play');
|
||||
|
||||
const handleSetFrameRate = (frameRate: number) => {
|
||||
if (frameRate < minFrameRate || frameRate > maxFrameRate) {
|
||||
return;
|
||||
}
|
||||
setFrameRate(frameRate);
|
||||
debouncedSetFrameRate(frameRate);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFrameRate(defaultFrameRate);
|
||||
}, [defaultFrameRate]);
|
||||
|
||||
const handleTimePointChange = useCallback(
|
||||
(newIndex: number) => {
|
||||
if (isDynamic && dynamicInfo) {
|
||||
// Here, you would update the component's state or context that controls the current time point index
|
||||
// For demonstration, assuming a hypothetical function that updates the time point index
|
||||
updateDynamicInfo({
|
||||
...dynamicInfo,
|
||||
timePointIndex: newIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isDynamic, dynamicInfo]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{isDynamic && dynamicInfo && (
|
||||
<InputRange
|
||||
value={dynamicInfo.timePointIndex}
|
||||
onChange={handleTimePointChange}
|
||||
minValue={0}
|
||||
maxValue={dynamicInfo.numTimePoints - 1}
|
||||
step={1}
|
||||
containerClassName="mb-3 w-full"
|
||||
labelClassName="text-xs text-white"
|
||||
leftColor="#3a3f99"
|
||||
rightColor="#3a3f99"
|
||||
trackHeight="4px"
|
||||
thumbColor="#348cfd"
|
||||
thumbColorOuter="#000000"
|
||||
showLabel={false}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
'border-secondary-light/60 bg-primary-dark inline-flex select-none items-center gap-2 rounded border px-2 py-2'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={getPlayPauseIconName()}
|
||||
className="active:text-primary-light hover:bg-customblue-300 cursor-pointer text-white hover:rounded"
|
||||
onClick={() => onPlayPauseChange(!isPlaying)}
|
||||
data-cy={'cine-player-play-pause'}
|
||||
/>
|
||||
{isDynamic && dynamicInfo && (
|
||||
<div className="min-w-16 max-w-44 flex flex-col text-white">
|
||||
{/* Add Tailwind classes for monospace font and center alignment */}
|
||||
<div className="text-[11px]">
|
||||
<span className="w-2 text-white">{dynamicInfo.timePointIndex}</span>{' '}
|
||||
<span className="text-aqua-pale">{`/${dynamicInfo.numTimePoints}`}</span>
|
||||
</div>
|
||||
<div className="text-aqua-pale text-xs">{dynamicInfo.label}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-secondary-light ml-4 flex h-6 items-stretch gap-1 rounded border">
|
||||
<div
|
||||
className={`${fpsButtonClassNames} rounded-l`}
|
||||
onClick={() => handleSetFrameRate(frameRate - 1)}
|
||||
data-cy={'cine-player-left-arrow'}
|
||||
>
|
||||
<Icon name="arrow-left-small" />
|
||||
</div>
|
||||
<Tooltip
|
||||
position="top"
|
||||
className="group/fps cine-fps-range-tooltip"
|
||||
tight={true}
|
||||
content={
|
||||
<InputRange
|
||||
containerClassName="h-9 px-2"
|
||||
inputClassName="w-40"
|
||||
value={frameRate}
|
||||
minValue={minFrameRate}
|
||||
maxValue={maxFrameRate}
|
||||
step={stepFrameRate}
|
||||
onChange={handleSetFrameRate}
|
||||
showLabel={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<div className="flex-shrink-0 text-center text-sm leading-[22px] text-white">
|
||||
<span className="inline-block text-right">{`${frameRate} `}</span>
|
||||
<span className="text-aqua-pale whitespace-nowrap text-xs">{' FPS'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
className={`${fpsButtonClassNames} rounded-r`}
|
||||
onClick={() => handleSetFrameRate(frameRate + 1)}
|
||||
data-cy={'cine-player-right-arrow'}
|
||||
>
|
||||
<Icon name="arrow-right-small" />
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
name="icon-close"
|
||||
className="text-primary-active active:text-primary-light hover:bg-customblue-300 cursor-pointer hover:rounded"
|
||||
onClick={onClose}
|
||||
data-cy={'cine-player-close'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CinePlayer.propTypes = {
|
||||
/** Minimum value for range slider */
|
||||
minFrameRate: PropTypes.number,
|
||||
/** Maximum value for range slider */
|
||||
maxFrameRate: PropTypes.number,
|
||||
/** Increment range slider can "step" in either direction */
|
||||
stepFrameRate: PropTypes.number,
|
||||
frameRate: PropTypes.number,
|
||||
/** 'true' if playing, 'false' if paused */
|
||||
isPlaying: PropTypes.bool.isRequired,
|
||||
onPlayPauseChange: PropTypes.func,
|
||||
onFrameRateChange: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
isDynamic: PropTypes.bool,
|
||||
dynamicInfo: PropTypes.shape({
|
||||
timePointIndex: PropTypes.number,
|
||||
numTimePoints: PropTypes.number,
|
||||
label: PropTypes.string,
|
||||
}),
|
||||
};
|
||||
|
||||
export default CinePlayer;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { CinePlayer } from '../../../components';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: CinePlayer,
|
||||
title: 'Components/CinePlayer',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/CinePlayer"
|
||||
component={CinePlayer}
|
||||
/>
|
||||
|
||||
export const CinePlayerTemplate = args => (
|
||||
<div className="relative h-96 w-96 bg-black">
|
||||
<div className="absolute left-1/2 bottom-3 -translate-x-1/2">
|
||||
<CinePlayer {...args} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="CinePlayer"
|
||||
componentRelativePath="CinePlayer/CinePlayer.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
CinePlayer is a component that allows you to use as a boolean value
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
isDynamic: true,
|
||||
dynamicInfo: {
|
||||
timePointIndex: 5,
|
||||
numTimePoints: 20,
|
||||
label: 'TemporalPositionIdentifier',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{CinePlayerTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={CinePlayer} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="CinePlayer/__stories__/CinePlayer.stories.mdx" />
|
||||
2
platform/ui/src/components/CinePlayer/index.js
Normal file
2
platform/ui/src/components/CinePlayer/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import CinePlayer from './CinePlayer';
|
||||
export default CinePlayer;
|
||||
46
platform/ui/src/components/ContextMenu/ContextMenu.tsx
Normal file
46
platform/ui/src/components/ContextMenu/ContextMenu.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Typography from '../Typography';
|
||||
import Icon from '../Icon';
|
||||
|
||||
const ContextMenu = ({ items, ...props }) => {
|
||||
if (!items) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-cy="context-menu"
|
||||
className="bg-secondary-dark relative z-50 block w-48 rounded"
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-cy="context-menu-item"
|
||||
onClick={() => item.action(item, props)}
|
||||
style={{ justifyContent: 'space-between' }}
|
||||
className="hover:bg-primary-dark border-primary-dark flex cursor-pointer items-center border-b px-4 py-3 transition duration-300 last:border-b-0"
|
||||
>
|
||||
<Typography>{item.label}</Typography>
|
||||
{item.iconRight && (
|
||||
<Icon
|
||||
name={item.iconRight}
|
||||
className="inline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ContextMenu.propTypes = {
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
action: PropTypes.func.isRequired,
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
export default ContextMenu;
|
||||
@@ -0,0 +1,65 @@
|
||||
import ContextMenu from '../ContextMenu';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: ContextMenu,
|
||||
title: 'Modals/ContextMenu',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Modals/ContextMenu"
|
||||
component={ContextMenu}
|
||||
/>
|
||||
|
||||
export const contextMenueTemplate = createComponentTemplate(ContextMenu);
|
||||
|
||||
<Heading
|
||||
title="ContextMenu"
|
||||
componentRelativePath="ContextMenu/ContextMenu.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Context Menu is a component that is used to display a list of options to the user. This component
|
||||
can be used for use cases such as opening a list of options on user right click.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
items: [
|
||||
{
|
||||
label: 'Delete measurement',
|
||||
actionType: 'Delete',
|
||||
action: item => {
|
||||
window.alert(`${item.label} clicked`);
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
{
|
||||
label: 'Add Label',
|
||||
actionType: 'setLabel',
|
||||
action: item => {
|
||||
window.alert(`${item.label} clicked`);
|
||||
},
|
||||
value: {},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{contextMenueTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={ContextMenu} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="ContextMenu/__stories__/contextMenu.stories.mdx" />
|
||||
1
platform/ui/src/components/ContextMenu/index.ts
Normal file
1
platform/ui/src/components/ContextMenu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './ContextMenu';
|
||||
99
platform/ui/src/components/DateRange/DateRange.css
Normal file
99
platform/ui/src/components/DateRange/DateRange.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/** CONTAINER STYLES **/
|
||||
|
||||
.DateRangePickerInput {
|
||||
@apply flex border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.DateRangePicker_picker {
|
||||
@apply -mt-1;
|
||||
}
|
||||
|
||||
/** INPUT DIV STYLES **/
|
||||
|
||||
.DateInput {
|
||||
background: transparent;
|
||||
@apply flex w-auto flex-1;
|
||||
}
|
||||
|
||||
/** INPUT FIELD COMMON STYLES **/
|
||||
|
||||
.DateInput_input {
|
||||
/* used data:image as background-image because svg import with relative url didn't work */
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="%236b6b6b" fill-rule="evenodd"><path d="M20 20h-4v-4h4v4zm-6-10h-4v4h4v-4zm6 0h-4v4h4v-4zm-12 6h-4v4h4v-4zm6 0h-4v4h4v-4zm-6-6h-4v4h4v-4zm16-8v22h-24v-22h3v1c0 1.103.897 2 2 2s2-.897 2-2v-1h10v1c0 1.103.897 2 2 2s2-.897 2-2v-1h3zm-2 6h-20v14h20v-14zm-2-7c0-.552-.447-1-1-1s-1 .448-1 1v2c0 .552.447 1 1 1s1-.448 1-1v-2zm-14 2c0 .552-.447 1-1 1s-1-.448-1-1v-2c0-.552.447-1 1-1s1 .448 1 1v2z"/></g></svg>');
|
||||
|
||||
background-size: 14px;
|
||||
background-position: 10px center;
|
||||
@apply bg-no-repeat;
|
||||
}
|
||||
.DateInput_input {
|
||||
@apply border-primary-main mt-2 w-full cursor-pointer appearance-none rounded border-t border-l border-r border-b border-solid bg-black py-2 px-3 pl-8 text-sm font-light leading-tight text-white shadow transition duration-300;
|
||||
}
|
||||
.DateInput_input:hover {
|
||||
@apply border-gray-500;
|
||||
}
|
||||
.DateInput_input:focus {
|
||||
@apply border-gray-500 outline-none;
|
||||
}
|
||||
/** FIRST INPUT STYLES **/
|
||||
.DateInput:first-child .DateInput_input {
|
||||
@apply rounded-r-none;
|
||||
}
|
||||
|
||||
.DateInput:first-child .DateInput_input:hover,
|
||||
.DateInput:first-child .DateInput_input:focus {
|
||||
@apply relative z-10;
|
||||
}
|
||||
|
||||
/** SECOND INPUT STYLES **/
|
||||
.DateInput:last-child .DateInput_input {
|
||||
@apply rounded-l-none;
|
||||
margin-left: -1px;
|
||||
}
|
||||
/** ARROW STYLES **/
|
||||
.DateRangePickerInput_arrow {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
/* SELECT MONTH PICKER */
|
||||
.DateRangePicker_select {
|
||||
@apply text-secondary-active border-common-dark cursor-pointer appearance-none rounded border bg-white py-1 pl-2 pr-5 text-base; /* NEEDED FOR ARROW DOWN */
|
||||
background-image: linear-gradient(45deg, transparent 50%, gray 50%),
|
||||
linear-gradient(135deg, gray 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 11px) 11px,
|
||||
calc(100% - 6px) calc(11px);
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
/* CALENDAR DAYS */
|
||||
.CalendarDay {
|
||||
@apply rounded-full border-0;
|
||||
}
|
||||
|
||||
.CalendarDay:hover,
|
||||
.CalendarDay__selected,
|
||||
.CalendarDay__selected:active,
|
||||
.CalendarDay__selected:hover {
|
||||
@apply bg-primary-main border-primary-main border-0 text-white;
|
||||
}
|
||||
|
||||
.CalendarDay__blocked_out_of_range:hover {
|
||||
@apply text-common-dark cursor-not-allowed border-0 bg-white;
|
||||
}
|
||||
|
||||
.CalendarDay__selected_span {
|
||||
@apply bg-primary-light border-0;
|
||||
}
|
||||
|
||||
/* MONTH NAVIGATION BUTTONS */
|
||||
.DayPickerNavigation_button__horizontalDefault,
|
||||
.DayPickerNavigation_button__horizontalDefault:hover {
|
||||
@apply border-common-dark text-common-dark;
|
||||
top: 24px;
|
||||
padding: 3px 9px;
|
||||
}
|
||||
.DayPickerNavigation_svg__horizontal {
|
||||
@apply fill-current;
|
||||
}
|
||||
178
platform/ui/src/components/DateRange/DateRange.tsx
Normal file
178
platform/ui/src/components/DateRange/DateRange.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** REACT DATES */
|
||||
import { DateRangePicker, isInclusivelyBeforeDay } from 'react-dates';
|
||||
import 'react-dates/initialize';
|
||||
import 'react-dates/lib/css/_datepicker.css';
|
||||
import './DateRange.css';
|
||||
|
||||
const renderYearsOptions = () => {
|
||||
const currentYear = moment().year();
|
||||
const options = [];
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const year = currentYear - i;
|
||||
options.push(
|
||||
<option
|
||||
key={year}
|
||||
value={year}
|
||||
>
|
||||
{year}
|
||||
</option>
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const DateRange = props => {
|
||||
const { id = '', onChange, startDate = null, endDate = null } = props;
|
||||
const [focusedInput, setFocusedInput] = useState(null);
|
||||
const renderYearsOptionsCallback = useCallback(renderYearsOptions, []);
|
||||
const { t } = useTranslation('DatePicker');
|
||||
const today = moment();
|
||||
const lastWeek = moment().subtract(7, 'day');
|
||||
const lastMonth = moment().subtract(1, 'month');
|
||||
const studyDatePresets = [
|
||||
{
|
||||
text: t('Today'),
|
||||
start: today,
|
||||
end: today,
|
||||
},
|
||||
{
|
||||
text: t('Last 7 days'),
|
||||
start: lastWeek,
|
||||
end: today,
|
||||
},
|
||||
{
|
||||
text: t('Last 30 days'),
|
||||
start: lastMonth,
|
||||
end: today,
|
||||
},
|
||||
];
|
||||
|
||||
const renderDatePresets = () => {
|
||||
return (
|
||||
<div className="PresetDateRangePicker_panel flex justify-between">
|
||||
{studyDatePresets.map(({ text, start, end }) => {
|
||||
return (
|
||||
<button
|
||||
key={text}
|
||||
type="button"
|
||||
className={`bg-primary-main m-0 rounded border-0 py-2 px-3 text-base text-white transition duration-300 hover:opacity-80`}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
startDate: start ? start.format('YYYYMMDD') : undefined,
|
||||
endDate: end ? end.format('YYYYMMDD') : undefined,
|
||||
preset: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderMonthElement = ({ month, onMonthSelect, onYearSelect }) => {
|
||||
renderMonthElement.propTypes = {
|
||||
month: PropTypes.object,
|
||||
onMonthSelect: PropTypes.func,
|
||||
onYearSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
const handleMonthChange = event => {
|
||||
onMonthSelect(month, event.target.value);
|
||||
};
|
||||
|
||||
const handleYearChange = event => {
|
||||
onYearSelect(month, event.target.value);
|
||||
};
|
||||
|
||||
const handleOnBlur = () => {};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="my-0 mx-1">
|
||||
<select
|
||||
className="DateRangePicker_select"
|
||||
value={month.month()}
|
||||
onChange={handleMonthChange}
|
||||
onBlur={handleOnBlur}
|
||||
>
|
||||
{moment.months().map((label, value) => (
|
||||
<option
|
||||
key={value}
|
||||
value={value}
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="my-0 mx-1">
|
||||
<select
|
||||
className="DateRangePicker_select"
|
||||
value={month.year()}
|
||||
onChange={handleYearChange}
|
||||
onBlur={handleOnBlur}
|
||||
>
|
||||
{renderYearsOptionsCallback()}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Moment
|
||||
const parsedStartDate = startDate ? moment(startDate, 'YYYYMMDD') : null;
|
||||
const parsedEndDate = endDate ? moment(endDate, 'YYYYMMDD') : null;
|
||||
|
||||
return (
|
||||
<DateRangePicker
|
||||
/** REQUIRED */
|
||||
startDate={parsedStartDate}
|
||||
startDateId={`date-range-${id}-start-date`}
|
||||
endDate={parsedEndDate}
|
||||
endDateId={`date-range-${id}-end-date`}
|
||||
onDatesChange={({ startDate: newStartDate, endDate: newEndDate }) => {
|
||||
onChange({
|
||||
startDate: newStartDate ? newStartDate.format('YYYYMMDD') : undefined,
|
||||
endDate: newEndDate ? newEndDate.format('YYYYMMDD') : undefined,
|
||||
});
|
||||
}}
|
||||
focusedInput={focusedInput}
|
||||
onFocusChange={updatedVal => setFocusedInput(updatedVal)}
|
||||
/** OPTIONAL */
|
||||
renderCalendarInfo={renderDatePresets}
|
||||
renderMonthElement={renderMonthElement}
|
||||
startDatePlaceholderText={t('Start Date')}
|
||||
endDatePlaceholderText={t('End Date')}
|
||||
phrases={{
|
||||
closeDatePicker: t('Close'),
|
||||
clearDates: t('Clear dates'),
|
||||
}}
|
||||
isOutsideRange={day => !isInclusivelyBeforeDay(day, moment())}
|
||||
hideKeyboardShortcutsPanel={true}
|
||||
numberOfMonths={1}
|
||||
showClearDates={false}
|
||||
anchorDirection="left"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
DateRange.propTypes = {
|
||||
id: PropTypes.string,
|
||||
/** YYYYMMDD (19921022) */
|
||||
startDate: PropTypes.string,
|
||||
/** YYYYMMDD (19921022) */
|
||||
endDate: PropTypes.string,
|
||||
/** Callback that received { startDate: string(YYYYMMDD), endDate: string(YYYYMMDD)} */
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DateRange;
|
||||
@@ -0,0 +1,52 @@
|
||||
import DateRange from '../DateRange';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: DateRange,
|
||||
title: 'Components/DateRange',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/DateRange"
|
||||
component={DateRange}
|
||||
/>
|
||||
|
||||
export const DateRangeTemplate = args => (
|
||||
<div className="h-96">
|
||||
<DateRange {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="DateRange"
|
||||
componentRelativePath="DateRange/DateRange.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
DateRange is a component that allows you to select a range of dates.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
id: 'date-range-1',
|
||||
startDate: '1990-05-01',
|
||||
endDate: '2022-01-01',
|
||||
}}
|
||||
>
|
||||
{DateRangeTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={DateRange} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="DateRange/__stories__/dateRange.stories.mdx" />
|
||||
3
platform/ui/src/components/DateRange/index.js
Normal file
3
platform/ui/src/components/DateRange/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import DateRange from './DateRange';
|
||||
|
||||
export default DateRange;
|
||||
27
platform/ui/src/components/Dialog/Body.tsx
Normal file
27
platform/ui/src/components/Dialog/Body.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Typography from '../Typography';
|
||||
|
||||
const Body = ({ text, className }) => {
|
||||
const theme = 'bg-primary-dark';
|
||||
return (
|
||||
<div className={classNames('relative flex-auto', theme, className)}>
|
||||
<Typography
|
||||
variant="inherit"
|
||||
color="initial"
|
||||
className="text-[14px] !leading-[1.2]"
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Body.propTypes = {
|
||||
text: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Body;
|
||||
84
platform/ui/src/components/Dialog/Dialog.tsx
Normal file
84
platform/ui/src/components/Dialog/Dialog.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Footer from './Footer';
|
||||
import Body from './Body';
|
||||
import Header from './Header';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const Dialog = ({
|
||||
title,
|
||||
text,
|
||||
onClose,
|
||||
noCloseButton,
|
||||
actions,
|
||||
onShow,
|
||||
onSubmit,
|
||||
header: HeaderComponent = Header,
|
||||
body: BodyComponent = Body,
|
||||
footer: FooterComponent = Footer,
|
||||
value: defaultValue = {},
|
||||
}) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
const theme = 'bg-primary-dark';
|
||||
const flex = 'flex flex-col';
|
||||
const border = 'border-0 rounded';
|
||||
const outline = 'outline-none focus:outline-none';
|
||||
const position = 'relative';
|
||||
const width = 'w-full';
|
||||
const padding = 'px-[20px] pb-[20px] pt-[13px]';
|
||||
|
||||
useEffect(() => {
|
||||
if (onShow) {
|
||||
onShow();
|
||||
}
|
||||
}, [onShow]);
|
||||
|
||||
return (
|
||||
<div className={classNames(theme, flex, border, outline, position, width, padding)}>
|
||||
<HeaderComponent
|
||||
title={title}
|
||||
noCloseButton={noCloseButton}
|
||||
onClose={onClose}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
<BodyComponent
|
||||
text={text}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
<FooterComponent
|
||||
actions={actions}
|
||||
onSubmit={onSubmit}
|
||||
value={value}
|
||||
setValue={setValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Dialog.propTypes = {
|
||||
title: PropTypes.string,
|
||||
text: PropTypes.string,
|
||||
onClose: PropTypes.func,
|
||||
noCloseButton: PropTypes.bool,
|
||||
header: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
body: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
footer: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
value: PropTypes.object,
|
||||
actions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
value: PropTypes.any,
|
||||
type: PropTypes.oneOf(['primary', 'secondary', 'cancel']).isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
onShow: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Dialog;
|
||||
50
platform/ui/src/components/Dialog/Footer.tsx
Normal file
50
platform/ui/src/components/Dialog/Footer.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Button, { ButtonEnums } from '../Button';
|
||||
|
||||
const Footer = ({ actions = [], className, onSubmit = () => {}, value }) => {
|
||||
const flex = 'flex items-center justify-end';
|
||||
const padding = 'pt-[20px]';
|
||||
|
||||
return (
|
||||
<div className={classNames(flex, padding, className)}>
|
||||
{actions?.map((action, index) => {
|
||||
const isFirst = index === 0;
|
||||
|
||||
const onClickHandler = event => onSubmit({ action, value, event });
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
name={action.text}
|
||||
className={classNames({ 'ml-2': !isFirst }, action.classes)}
|
||||
type={action.type}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
{action.text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
Footer.propTypes = {
|
||||
className: PropTypes.string,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
actions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
value: PropTypes.any,
|
||||
type: PropTypes.oneOf([ButtonEnums.type.primary, ButtonEnums.type.secondary]).isRequired,
|
||||
classes: PropTypes.arrayOf(PropTypes.string),
|
||||
})
|
||||
).isRequired,
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
49
platform/ui/src/components/Dialog/Header.tsx
Normal file
49
platform/ui/src/components/Dialog/Header.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Typography from '../Typography';
|
||||
import Icon from '../Icon';
|
||||
|
||||
const CloseButton = ({ onClick }) => {
|
||||
return (
|
||||
<Icon
|
||||
data-cy="close-button"
|
||||
onClick={onClick}
|
||||
name="close"
|
||||
className="text-primary-active cursor-pointer"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CloseButton.propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
const Header = ({ title, noCloseButton = false, onClose }) => {
|
||||
const theme = 'bg-primary-dark';
|
||||
const flex = 'flex items-center justify-between';
|
||||
const padding = 'pb-[20px]';
|
||||
|
||||
return (
|
||||
<div className={classNames(theme, flex, padding)}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
color="primaryLight"
|
||||
className="!leading-[1.2]"
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{!noCloseButton && <CloseButton onClick={onClose} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Header.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
noCloseButton: PropTypes.bool,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,73 @@
|
||||
import Dialog from '../Dialog';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Dialog,
|
||||
title: 'Modals/Dialog',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Modals/Dialog"
|
||||
component={Dialog}
|
||||
/>
|
||||
|
||||
export const DialogTemplate = createComponentTemplate(Dialog);
|
||||
|
||||
<Heading
|
||||
title="Dialog"
|
||||
componentRelativePath="Dialog/Dialog.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Dialog is a modal dialog component which enabled to show a modal dialog.
|
||||
|
||||
<Canvas>
|
||||
<Story name="Overview">{DialogTemplate.bind({})}</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Dialog} />
|
||||
|
||||
## Usage
|
||||
|
||||
It can be used to show a modal dialog to submit a form or show a message.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Submit"
|
||||
args={{
|
||||
title: 'Dialog Title',
|
||||
text: 'Dialog Text',
|
||||
onClose: () => {
|
||||
window.alert('Dialog closed');
|
||||
},
|
||||
noCloseButton: false,
|
||||
actions: [
|
||||
{
|
||||
id: 'cancel',
|
||||
text: 'Cancel',
|
||||
type: 'cancel',
|
||||
},
|
||||
{
|
||||
id: 'submit',
|
||||
text: 'Submit',
|
||||
type: 'primary',
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{DialogTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Dialog/__stories__/dialog.stories.mdx" />
|
||||
2
platform/ui/src/components/Dialog/index.js
Normal file
2
platform/ui/src/components/Dialog/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import Dialog from './Dialog';
|
||||
export default Dialog;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import PortalTooltip from '../Tooltip/PortalTooltip';
|
||||
import Icon from '../Icon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Displays a tooltip with a list of messages of a displaySet
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
const DisplaySetMessageListTooltip = ({ messages, id }): React.ReactNode => {
|
||||
const { t } = useTranslation('Messages');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
if (messages?.size()) {
|
||||
return (
|
||||
<>
|
||||
<Icon
|
||||
id={id}
|
||||
onMouseOver={() => setIsOpen(true)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onMouseOut={() => setIsOpen(false)}
|
||||
onBlur={() => setIsOpen(false)}
|
||||
name="status-alert-warning"
|
||||
/>
|
||||
<PortalTooltip
|
||||
active={isOpen}
|
||||
position="right"
|
||||
arrow="center"
|
||||
parent={`#${id}`}
|
||||
>
|
||||
<div className="bg-primary-dark border-secondary-light max-w-64 rounded border text-left text-base text-white">
|
||||
<div
|
||||
className="break-normal text-base font-bold text-blue-300"
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
{t('Display Set Messages')}
|
||||
</div>
|
||||
<ol
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
marginRight: '12px',
|
||||
}}
|
||||
>
|
||||
{messages.messages.map((message, index) => (
|
||||
<li
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
key={index}
|
||||
>
|
||||
{index + 1}. {t(message.id)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</PortalTooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <></>;
|
||||
};
|
||||
|
||||
DisplaySetMessageListTooltip.propTypes = {
|
||||
messages: PropTypes.object,
|
||||
};
|
||||
|
||||
export default DisplaySetMessageListTooltip;
|
||||
@@ -0,0 +1,2 @@
|
||||
import DisplaySetMessageListTooltip from './DisplaySetMessageListTooltip';
|
||||
export default DisplaySetMessageListTooltip;
|
||||
221
platform/ui/src/components/Dropdown/Dropdown.tsx
Normal file
221
platform/ui/src/components/Dropdown/Dropdown.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useEffect, useCallback, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import Icon from '../Icon';
|
||||
import Typography from '../Typography';
|
||||
|
||||
const borderStyle = 'border-b last:border-b-0 border-secondary-main';
|
||||
|
||||
const Dropdown = ({
|
||||
id,
|
||||
children,
|
||||
showDropdownIcon = true,
|
||||
list,
|
||||
itemsClassName,
|
||||
titleClassName,
|
||||
showBorders = true,
|
||||
alignment,
|
||||
// By default the max characters per line is the longest title
|
||||
// if you wish to override this, you can pass in a number
|
||||
maxCharactersPerLine = 20,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const elementRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
const [coords, setCoords] = useState({ x: 0, y: 0 });
|
||||
|
||||
// choose the max characters per line based on the longest title
|
||||
const longestTitle = list.reduce((acc, item) => {
|
||||
if (item.title.length > acc) {
|
||||
return item.title.length;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
|
||||
maxCharactersPerLine = maxCharactersPerLine ?? longestTitle;
|
||||
|
||||
const DropdownItem = useCallback(
|
||||
({ id, title, icon, onClick }) => {
|
||||
// Split the title into lines of length maxCharactersPerLine
|
||||
const lines = [];
|
||||
for (let i = 0; i < title.length; i += maxCharactersPerLine) {
|
||||
lines.push(title.substring(i, i + maxCharactersPerLine));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={title}
|
||||
className={classnames(
|
||||
'hover:bg-secondary-main flex cursor-pointer items-center px-4 py-2 transition duration-300 ',
|
||||
titleClassName,
|
||||
showBorders && borderStyle
|
||||
)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onClick();
|
||||
}}
|
||||
data-cy={id}
|
||||
>
|
||||
{!!icon && (
|
||||
<Icon
|
||||
name={icon}
|
||||
className="mr-2 w-4 text-white"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{title.length > maxCharactersPerLine && (
|
||||
<div>
|
||||
{lines.map((line, index) => (
|
||||
<Typography
|
||||
key={index}
|
||||
className={itemsClassName}
|
||||
>
|
||||
{line}
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{title.length <= maxCharactersPerLine && (
|
||||
<Typography className={itemsClassName}>{title}</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[maxCharactersPerLine, itemsClassName, titleClassName, showBorders]
|
||||
);
|
||||
|
||||
const renderTitleElement = () => {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{children}
|
||||
{showDropdownIcon && (
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
className="ml-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const toggleList = () => {
|
||||
setOpen(s => !s);
|
||||
};
|
||||
|
||||
const handleClick = e => {
|
||||
if (elementRef.current && !elementRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (elementRef.current && dropdownRef.current) {
|
||||
const triggerRect = elementRef.current.getBoundingClientRect();
|
||||
const dropdownRect = dropdownRef.current.getBoundingClientRect();
|
||||
let x, y;
|
||||
|
||||
switch (alignment) {
|
||||
case 'right':
|
||||
x = triggerRect.right + window.scrollX - dropdownRect.width;
|
||||
y = triggerRect.bottom + window.scrollY;
|
||||
break;
|
||||
case 'left':
|
||||
x = triggerRect.left + window.scrollX;
|
||||
y = triggerRect.bottom + window.scrollY;
|
||||
break;
|
||||
default:
|
||||
x = triggerRect.left + window.scrollX;
|
||||
y = triggerRect.bottom + window.scrollY;
|
||||
break;
|
||||
}
|
||||
setCoords({ x, y });
|
||||
}
|
||||
}, [open, alignment, elementRef.current, dropdownRef.current]);
|
||||
|
||||
const renderList = () => {
|
||||
const portalElement = document.getElementById('react-portal');
|
||||
|
||||
const listElement = (
|
||||
<div
|
||||
className={classnames(
|
||||
'top-100 border-secondary-main w-max-content absolute mt-2 transform rounded border bg-black shadow transition duration-300',
|
||||
{
|
||||
'right-0 origin-top-right': alignment === 'right',
|
||||
'left-0 origin-top-left': alignment === 'left',
|
||||
}
|
||||
)}
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${coords.y}px`,
|
||||
left: open ? `${coords.x}px` : -999999,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
data-cy={`${id}-dropdown`}
|
||||
>
|
||||
{list.map((item, idx) => (
|
||||
<DropdownItem
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
onClick={item.onClick}
|
||||
key={idx}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return ReactDOM.createPortal(listElement, portalElement);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleClick);
|
||||
|
||||
if (!open) {
|
||||
document.removeEventListener('click', handleClick);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-cy="dropdown"
|
||||
ref={elementRef}
|
||||
className="relative"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={toggleList}
|
||||
>
|
||||
{renderTitleElement()}
|
||||
</div>
|
||||
|
||||
{renderList()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Dropdown.propTypes = {
|
||||
id: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
showDropdownIcon: PropTypes.bool,
|
||||
titleClassName: PropTypes.string,
|
||||
/** Items to render in the select's drop down */
|
||||
list: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
alignment: PropTypes.oneOf(['left', 'right']),
|
||||
maxCharactersPerLine: PropTypes.number,
|
||||
showBorders: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
@@ -0,0 +1,84 @@
|
||||
import Dropdown from '../Dropdown';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Dropdown,
|
||||
title: 'Components/Dropdown',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/Dropdown"
|
||||
component={Dropdown}
|
||||
/>
|
||||
|
||||
export const DropdownTemplate = args => (
|
||||
// Todo: this should not set a background
|
||||
<div className="flex h-32">
|
||||
<Dropdown {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="Dropdown"
|
||||
componentRelativePath="Dropdown/Dropdown.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Dropdown is a modal Dropdown component which enabled to show a modal Dropdown.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{ id: 'dropdown-d', list: [] }}
|
||||
>
|
||||
{DropdownTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Dropdown} />
|
||||
|
||||
## Usage
|
||||
|
||||
You can list the items in the dropdown.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="List"
|
||||
args={{
|
||||
id: 'dropdown-1',
|
||||
children: <div className="text-black">Drop Down</div>,
|
||||
showDropdownIcon: true,
|
||||
list: [
|
||||
{
|
||||
title: 'Item 1',
|
||||
icon: 'clipboard',
|
||||
onClick: () => {
|
||||
alert('Item 1 clicked');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Item 2',
|
||||
icon: 'tracked',
|
||||
onClick: () => {
|
||||
alert('Item 2 clicked');
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{DropdownTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Dropdown/__stories__/dropdown.stories.mdx" />
|
||||
1
platform/ui/src/components/Dropdown/index.js
Normal file
1
platform/ui/src/components/Dropdown/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Dropdown';
|
||||
32
platform/ui/src/components/EmptyStudies/EmptyStudies.tsx
Normal file
32
platform/ui/src/components/EmptyStudies/EmptyStudies.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Icon from '../Icon';
|
||||
import Typography from '../Typography';
|
||||
|
||||
// TODO: Add loading spinner to OHIF + use it here.
|
||||
const EmptyStudies = ({ className = '' }) => {
|
||||
const { t } = useTranslation('StudyList');
|
||||
return (
|
||||
<div className={classnames('inline-flex flex-col items-center', className)}>
|
||||
<Icon
|
||||
name="magnifier"
|
||||
className="mb-4"
|
||||
/>
|
||||
<Typography
|
||||
className="text-primary-light"
|
||||
variant="h5"
|
||||
>
|
||||
{t('No studies available')}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EmptyStudies.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default EmptyStudies;
|
||||
2
platform/ui/src/components/EmptyStudies/index.js
Normal file
2
platform/ui/src/components/EmptyStudies/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import EmptyStudies from './EmptyStudies';
|
||||
export default EmptyStudies;
|
||||
129
platform/ui/src/components/ErrorBoundary/ErrorBoundary.tsx
Normal file
129
platform/ui/src/components/ErrorBoundary/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import Modal from '../Modal';
|
||||
import Icon from '../Icon';
|
||||
import IconButton from '../IconButton';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const DefaultFallback = ({ error, context, resetErrorBoundary = () => {}, fallbackRoute }) => {
|
||||
const { t } = useTranslation('ErrorBoundary');
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const title = `${t('Something went wrong')}${!isProduction && ` ${t('in')} ${context}`}.`;
|
||||
const subtitle = t('Sorry, something went wrong there. Try again.');
|
||||
return (
|
||||
<div
|
||||
className="ErrorFallback bg-primary-dark h-full w-full"
|
||||
role="alert"
|
||||
>
|
||||
<p className="text-primary-light text-xl">{title}</p>
|
||||
<p className="text-primary-light text-base">{subtitle}</p>
|
||||
{!isProduction && (
|
||||
<div className="bg-secondary-dark mt-5 space-y-2 rounded-md p-5 font-mono">
|
||||
<p className="text-primary-light">
|
||||
{t('Context')}: {context}
|
||||
</p>
|
||||
<p className="text-primary-light">
|
||||
{t('Error Message')}: {error.message}
|
||||
</p>
|
||||
|
||||
<IconButton
|
||||
variant="contained"
|
||||
color="inherit"
|
||||
size="initial"
|
||||
className="text-primary-active"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
<React.Fragment>
|
||||
<div>{t('Stack Trace')}</div>
|
||||
<Icon
|
||||
width="15px"
|
||||
height="15px"
|
||||
name="chevron-down"
|
||||
/>
|
||||
</React.Fragment>
|
||||
</IconButton>
|
||||
|
||||
{showDetails && (
|
||||
<pre className="text-primary-light whitespace-pre-wrap px-4">Stack: {error.stack}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DefaultFallback.propTypes = {
|
||||
error: PropTypes.object.isRequired,
|
||||
resetErrorBoundary: PropTypes.func,
|
||||
componentStack: PropTypes.string,
|
||||
};
|
||||
|
||||
const ErrorBoundary = ({
|
||||
context = 'OHIF',
|
||||
onReset = () => {},
|
||||
onError = () => {},
|
||||
fallbackComponent: FallbackComponent = DefaultFallback,
|
||||
children,
|
||||
fallbackRoute = null,
|
||||
isPage,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const onErrorHandler = (error, componentStack) => {
|
||||
console.error(`${context} Error Boundary`, error, componentStack, context);
|
||||
onError(error, componentStack, context);
|
||||
};
|
||||
|
||||
const onResetHandler = (...args) => onReset(...args);
|
||||
|
||||
const withModal = Component => props => (
|
||||
<Modal
|
||||
closeButton
|
||||
shouldCloseOnEsc
|
||||
isOpen={isOpen}
|
||||
title={i18n.t('ErrorBoundary:Something went wrong')}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
if (fallbackRoute && typeof window !== 'undefined') {
|
||||
window.location = fallbackRoute;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Component {...props} />
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const Fallback = isPage ? FallbackComponent : withModal(FallbackComponent);
|
||||
|
||||
return (
|
||||
<ReactErrorBoundary
|
||||
fallbackRender={props => (
|
||||
<Fallback
|
||||
{...props}
|
||||
context={context}
|
||||
fallbackRoute={fallbackRoute}
|
||||
/>
|
||||
)}
|
||||
onReset={onResetHandler}
|
||||
onError={onErrorHandler}
|
||||
>
|
||||
{children}
|
||||
</ReactErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
ErrorBoundary.propTypes = {
|
||||
context: PropTypes.string,
|
||||
onReset: PropTypes.func,
|
||||
onError: PropTypes.func,
|
||||
fallbackComponent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
children: PropTypes.node.isRequired,
|
||||
fallbackRoute: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
2
platform/ui/src/components/ErrorBoundary/index.js
Normal file
2
platform/ui/src/components/ErrorBoundary/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,16 @@
|
||||
.ExpandableToolbarButton:hover .ExpandableToolbarButton__arrow:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
border-width: 10px 10px 0;
|
||||
border-style: solid;
|
||||
border-color: #5acce6 transparent;
|
||||
}
|
||||
|
||||
.ExpandableToolbarButton .ExpandableToolbarButton__content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ExpandableToolbarButton:hover .ExpandableToolbarButton__content {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import IconButton from '../IconButton';
|
||||
import Icon from '../Icon';
|
||||
|
||||
import './ExpandableToolbarButton.css';
|
||||
|
||||
const ExpandableToolbarButton = ({
|
||||
type = 'primary',
|
||||
id = '',
|
||||
isActive = false,
|
||||
onClick = () => {},
|
||||
icon = 'clipboard',
|
||||
className,
|
||||
content: Content = null,
|
||||
contentProps = {},
|
||||
}) => {
|
||||
const classes = {
|
||||
type: {
|
||||
primary: isActive
|
||||
? 'text-black'
|
||||
: 'text-common-bright hover:bg-primary-dark hover:text-primary-light',
|
||||
secondary: isActive
|
||||
? 'text-black'
|
||||
: 'text-white hover:bg-secondary-dark focus:bg-secondary-dark',
|
||||
},
|
||||
};
|
||||
|
||||
const onChildClickHandler = (...args) => {
|
||||
onClick(...args);
|
||||
|
||||
if (contentProps.onClick) {
|
||||
contentProps.onClick(...args);
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHandler = (...args) => {
|
||||
onClick(...args);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="ExpandableToolbarButton"
|
||||
>
|
||||
<IconButton
|
||||
variant={isActive ? 'contained' : 'text'}
|
||||
className={classnames(
|
||||
'mx-1',
|
||||
classes.type[type],
|
||||
isActive && 'ExpandableToolbarButton__arrow'
|
||||
)}
|
||||
onClick={onClickHandler}
|
||||
key={id}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</IconButton>
|
||||
<div className="absolute z-10 pt-4">
|
||||
<div className={classnames('ExpandableToolbarButton__content w-48', className)}>
|
||||
<Content
|
||||
{...contentProps}
|
||||
onClick={onChildClickHandler}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
ExpandableToolbarButton.propTypes = {
|
||||
/* Influences background/hover styling */
|
||||
type: PropTypes.oneOf(['primary', 'secondary']),
|
||||
id: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
icon: PropTypes.string.isRequired,
|
||||
/** Expandable toolbar button content can be replaced for a customized content by passing a node to this value. */
|
||||
content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
contentProps: PropTypes.object,
|
||||
};
|
||||
|
||||
export default ExpandableToolbarButton;
|
||||
@@ -0,0 +1,2 @@
|
||||
import ExpandableToolbarButton from './ExpandableToolbarButton';
|
||||
export default ExpandableToolbarButton;
|
||||
116
platform/ui/src/components/Header/Header.tsx
Normal file
116
platform/ui/src/components/Header/Header.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import NavBar from '../NavBar';
|
||||
import Svg from '../Svg';
|
||||
import Icon from '../Icon';
|
||||
import IconButton from '../IconButton';
|
||||
import Dropdown from '../Dropdown';
|
||||
import HeaderPatientInfo from '../HeaderPatientInfo';
|
||||
import { PatientInfoVisibility } from '../../types/PatientInfoVisibility';
|
||||
|
||||
function Header({
|
||||
children,
|
||||
menuOptions,
|
||||
isReturnEnabled = true,
|
||||
onClickReturnButton,
|
||||
isSticky = false,
|
||||
WhiteLabeling,
|
||||
showPatientInfo = PatientInfoVisibility.VISIBLE_COLLAPSED,
|
||||
servicesManager,
|
||||
Secondary,
|
||||
appConfig,
|
||||
...props
|
||||
}: withAppTypes): ReactNode {
|
||||
const { t } = useTranslation('Header');
|
||||
|
||||
// TODO: this should be passed in as a prop instead and the react-router-dom
|
||||
// dependency should be dropped
|
||||
const onClickReturn = () => {
|
||||
if (isReturnEnabled && onClickReturnButton) {
|
||||
onClickReturnButton();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NavBar
|
||||
isSticky={isSticky}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative h-[48px] items-center ">
|
||||
<div className="absolute left-0 top-1/2 flex -translate-y-1/2 items-center">
|
||||
<div
|
||||
className={classNames(
|
||||
'mr-3 inline-flex items-center',
|
||||
isReturnEnabled && 'cursor-pointer'
|
||||
)}
|
||||
onClick={onClickReturn}
|
||||
data-cy="return-to-work-list"
|
||||
>
|
||||
{isReturnEnabled && (
|
||||
<Icon
|
||||
name="chevron-left"
|
||||
className="text-primary-active w-8"
|
||||
/>
|
||||
)}
|
||||
<div className="ml-1">
|
||||
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Svg name="logo-ohif" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-1/2 left-[250px] h-8 -translate-y-1/2">{Secondary}</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 flex -translate-y-1/2 select-none items-center">
|
||||
{showPatientInfo !== PatientInfoVisibility.DISABLED && (
|
||||
<HeaderPatientInfo
|
||||
servicesManager={servicesManager}
|
||||
appConfig={appConfig}
|
||||
/>
|
||||
)}
|
||||
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
|
||||
<div className="flex-shrink-0">
|
||||
<Dropdown
|
||||
id="options"
|
||||
showDropdownIcon={false}
|
||||
list={menuOptions}
|
||||
alignment="right"
|
||||
>
|
||||
<IconButton
|
||||
id={'options-settings-icon'}
|
||||
variant="text"
|
||||
color="inherit"
|
||||
size="initial"
|
||||
className="text-primary-active hover:bg-primary-dark h-full w-full"
|
||||
>
|
||||
<Icon name="icon-settings" />
|
||||
</IconButton>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NavBar>
|
||||
);
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
menuOptions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
icon: PropTypes.string,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
})
|
||||
),
|
||||
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
|
||||
isReturnEnabled: PropTypes.bool,
|
||||
isSticky: PropTypes.bool,
|
||||
onClickReturnButton: PropTypes.func,
|
||||
WhiteLabeling: PropTypes.object,
|
||||
showPatientInfo: PropTypes.string,
|
||||
servicesManager: PropTypes.object,
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,57 @@
|
||||
import Header from '../Header';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Header,
|
||||
title: 'Components/Header',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/Header"
|
||||
component={Header}
|
||||
/>
|
||||
|
||||
export const HeaderTemplate = createComponentTemplate(Header);
|
||||
|
||||
<Heading
|
||||
title="Header"
|
||||
componentRelativePath="Header/Header.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Header is a component that renders the header of the application.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
menuOptions: [
|
||||
{
|
||||
title: 'About',
|
||||
icon: 'info',
|
||||
onClick: () => window.alert('About clicked'),
|
||||
},
|
||||
],
|
||||
isReturnEnabled: true,
|
||||
isSticky: false,
|
||||
onClickReturnButton: () => window.alert('Return clicked'),
|
||||
}}
|
||||
>
|
||||
{HeaderTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Header} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Header/__stories__/header.stories.mdx" />
|
||||
2
platform/ui/src/components/Header/index.js
Normal file
2
platform/ui/src/components/Header/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import Header from './Header';
|
||||
export default Header;
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon } from '@ohif/ui';
|
||||
import { utils } from '@ohif/core';
|
||||
import { PatientInfoVisibility } from '../../types';
|
||||
|
||||
const { formatDate, formatPN } = utils;
|
||||
|
||||
const formatWithEllipsis = (str, maxLength) => {
|
||||
if (str?.length > maxLength) {
|
||||
return str.substring(0, maxLength) + '...';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
function usePatientInfo(servicesManager: AppTypes.ServicesManager) {
|
||||
const { displaySetService } = servicesManager.services;
|
||||
|
||||
const [patientInfo, setPatientInfo] = useState({
|
||||
PatientName: '',
|
||||
PatientID: '',
|
||||
PatientSex: '',
|
||||
PatientDOB: '',
|
||||
});
|
||||
const [isMixedPatients, setIsMixedPatients] = useState(false);
|
||||
const displaySets = displaySetService.getActiveDisplaySets();
|
||||
|
||||
const checkMixedPatients = PatientID => {
|
||||
const displaySets = displaySetService.getActiveDisplaySets();
|
||||
let isMixedPatients = false;
|
||||
displaySets.forEach(displaySet => {
|
||||
const instance = displaySet?.instances?.[0] || displaySet?.instance;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
if (instance.PatientID !== PatientID) {
|
||||
isMixedPatients = true;
|
||||
}
|
||||
});
|
||||
setIsMixedPatients(isMixedPatients);
|
||||
};
|
||||
|
||||
const updatePatientInfo = () => {
|
||||
const displaySet = displaySets[0];
|
||||
const instance = displaySet?.instances?.[0] || displaySet?.instance;
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
setPatientInfo({
|
||||
PatientID: instance.PatientID || null,
|
||||
PatientName: instance.PatientName ? formatPN(instance.PatientName.Alphabetic) : null,
|
||||
PatientSex: instance.PatientSex || null,
|
||||
PatientDOB: formatDate(instance.PatientBirthDate) || null,
|
||||
});
|
||||
checkMixedPatients(instance.PatientID || null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
|
||||
() => updatePatientInfo()
|
||||
);
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updatePatientInfo();
|
||||
}, [displaySets]);
|
||||
|
||||
return { patientInfo, isMixedPatients };
|
||||
}
|
||||
|
||||
function HeaderPatientInfo({ servicesManager, appConfig }: withAppTypes) {
|
||||
const initialExpandedState =
|
||||
appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE ||
|
||||
appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE_READONLY;
|
||||
const [expanded, setExpanded] = useState(initialExpandedState);
|
||||
const { patientInfo, isMixedPatients } = usePatientInfo(servicesManager);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMixedPatients && expanded) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [isMixedPatients, expanded]);
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (!isMixedPatients && appConfig.showPatientInfo !== PatientInfoVisibility.VISIBLE_READONLY) {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
};
|
||||
|
||||
const formattedPatientName = formatWithEllipsis(patientInfo.PatientName, 27);
|
||||
const formattedPatientID = formatWithEllipsis(patientInfo.PatientID, 15);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hover:bg-primary-dark flex cursor-pointer items-center justify-center gap-1 rounded-lg"
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<Icon
|
||||
name={isMixedPatients ? 'icon-multiple-patients' : 'icon-patient'}
|
||||
className="text-primary-active"
|
||||
/>
|
||||
<div className="flex flex-col justify-center">
|
||||
{expanded ? (
|
||||
<>
|
||||
<div className="self-start text-[13px] font-bold text-white">
|
||||
{formattedPatientName}
|
||||
</div>
|
||||
<div className="text-aqua-pale flex gap-2 text-[11px]">
|
||||
<div>{formattedPatientID}</div>
|
||||
<div>{patientInfo.PatientSex}</div>
|
||||
<div>{patientInfo.PatientDOB}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-primary-active self-center text-[13px]">
|
||||
{' '}
|
||||
{isMixedPatients ? 'Multiple Patients' : 'Patient'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Icon
|
||||
name="icon-chevron-patient"
|
||||
className={`text-primary-active ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
HeaderPatientInfo.propTypes = {
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default HeaderPatientInfo;
|
||||
3
platform/ui/src/components/HeaderPatientInfo/index.js
Normal file
3
platform/ui/src/components/HeaderPatientInfo/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import HeaderPatientInfo from './HeaderPatientInfo';
|
||||
|
||||
export default HeaderPatientInfo;
|
||||
61
platform/ui/src/components/HotkeyField/HotkeyField.tsx
Normal file
61
platform/ui/src/components/HotkeyField/HotkeyField.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Input from '../Input';
|
||||
import { getKeys, formatKeysForInput } from './utils';
|
||||
|
||||
/**
|
||||
* HotkeyField
|
||||
* Renders a hotkey input that records keys
|
||||
*
|
||||
* @param {object} props component props
|
||||
* @param {Array[]} props.keys keys to be controlled by this field
|
||||
* @param {boolean} props.disabled disables the field
|
||||
* @param {function} props.onChange callback with changed values
|
||||
* @param {string} props.className input classes
|
||||
* @param {Array[]} props.modifierKeys
|
||||
*/
|
||||
const HotkeyField = ({ disabled = false, keys, onChange, className, modifierKeys, hotkeys }) => {
|
||||
const inputValue = formatKeysForInput(keys);
|
||||
|
||||
const onInputKeyDown = event => {
|
||||
hotkeys.record(sequence => {
|
||||
const keys = getKeys({ sequence, modifierKeys });
|
||||
hotkeys.unpause();
|
||||
onChange(keys);
|
||||
});
|
||||
};
|
||||
|
||||
const onFocus = () => {
|
||||
hotkeys.pause();
|
||||
hotkeys.startRecording();
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
value={inputValue}
|
||||
onKeyDown={onInputKeyDown}
|
||||
onFocus={onFocus}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
HotkeyField.propTypes = {
|
||||
keys: PropTypes.array.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
modifierKeys: PropTypes.array,
|
||||
disabled: PropTypes.bool,
|
||||
hotkeys: PropTypes.shape({
|
||||
initialize: PropTypes.func.isRequired,
|
||||
pause: PropTypes.func.isRequired,
|
||||
unpause: PropTypes.func.isRequired,
|
||||
startRecording: PropTypes.func.isRequired,
|
||||
record: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default HotkeyField;
|
||||
3
platform/ui/src/components/HotkeyField/index.js
Normal file
3
platform/ui/src/components/HotkeyField/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import HotkeyField from './HotkeyField.tsx';
|
||||
|
||||
export default HotkeyField;
|
||||
28
platform/ui/src/components/HotkeyField/utils.js
Normal file
28
platform/ui/src/components/HotkeyField/utils.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Take the pressed key array and return the readable string for the keys
|
||||
*
|
||||
* @param {Array} [keys=[]]
|
||||
* @returns {string} string representation of an array of keys
|
||||
*/
|
||||
const formatKeysForInput = (keys = []) => keys.join('+');
|
||||
|
||||
/**
|
||||
* formats given keys sequence to insert the modifier keys in the first index of the array
|
||||
* @param {string} sequence keys sequence from MouseTrap Record -> "shift+left"
|
||||
* @returns {Array} keys in array-format -> ['shift','left']
|
||||
*/
|
||||
const getKeys = ({ sequence, modifierKeys }) => {
|
||||
const keysArray = sequence.join(' ').split('+');
|
||||
let keys = [];
|
||||
let modifiers = [];
|
||||
keysArray.forEach(key => {
|
||||
if (modifierKeys && modifierKeys.includes(key)) {
|
||||
modifiers.push(key);
|
||||
} else {
|
||||
keys.push(key);
|
||||
}
|
||||
});
|
||||
return [...modifiers, ...keys];
|
||||
};
|
||||
|
||||
export { getKeys, formatKeysForInput };
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import HotkeyField from '../HotkeyField';
|
||||
import Typography from '../Typography';
|
||||
|
||||
/* TODO: Move these configs and utils to core? */
|
||||
import { MODIFIER_KEYS } from './hotkeysConfig';
|
||||
import { validate, splitHotkeyDefinitionsAndCreateTuples } from './utils';
|
||||
|
||||
const HotkeysPreferences = ({
|
||||
disabled = false,
|
||||
hotkeyDefinitions,
|
||||
errors: controlledErrors,
|
||||
onChange = () => {},
|
||||
hotkeysModule,
|
||||
}) => {
|
||||
const { t } = useTranslation('UserPreferencesModal');
|
||||
|
||||
const visibleHotkeys = Object.keys(hotkeyDefinitions)
|
||||
.filter(key => hotkeyDefinitions[key].isEditable)
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = hotkeyDefinitions[key];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
const [errors, setErrors] = useState(controlledErrors);
|
||||
const splitedHotkeys = splitHotkeyDefinitionsAndCreateTuples(visibleHotkeys);
|
||||
|
||||
if (!Object.keys(hotkeyDefinitions).length) {
|
||||
return t('No hotkeys found');
|
||||
}
|
||||
|
||||
const onHotkeyChangeHandler = (id, definition) => {
|
||||
const { error } = validate({
|
||||
commandName: id,
|
||||
pressedKeys: definition.keys,
|
||||
hotkeys: hotkeyDefinitions,
|
||||
});
|
||||
|
||||
setErrors(prevState => {
|
||||
const errors = { ...prevState, [id]: error };
|
||||
return errors;
|
||||
});
|
||||
|
||||
onChange(id, definition, { ...errors, [id]: error });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-center">
|
||||
<div className="flex w-full flex-row justify-evenly">
|
||||
{splitedHotkeys.map((hotkeys, index) => {
|
||||
return (
|
||||
<div
|
||||
key={`HotkeyGroup@${index}`}
|
||||
className="flex flex-row"
|
||||
>
|
||||
<div className="flex flex-col p-2 text-right">
|
||||
{hotkeys.map((hotkey, hotkeyIndex) => {
|
||||
const [id, definition] = hotkey;
|
||||
const isFirst = hotkeyIndex === 0;
|
||||
const error = errors[id];
|
||||
|
||||
const onChangeHandler = keys =>
|
||||
onHotkeyChangeHandler(id, { ...definition, keys });
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`HotkeyItem@${hotkeyIndex}`}
|
||||
className="mb-2 flex flex-row justify-end"
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
className={classNames(
|
||||
'text-primary-light w-full pr-6 text-right',
|
||||
!isFirst && 'hidden'
|
||||
)}
|
||||
>
|
||||
{t('Function')}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
className={classNames(
|
||||
'flex h-full flex-row items-center whitespace-nowrap pr-6',
|
||||
isFirst && 'mt-5'
|
||||
)}
|
||||
>
|
||||
{definition.label}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Typography
|
||||
variant="subtitle"
|
||||
className={classNames(
|
||||
'text-primary-light pr-6 pl-0 text-left',
|
||||
!isFirst && 'hidden'
|
||||
)}
|
||||
>
|
||||
{t('Shortcut')}
|
||||
</Typography>
|
||||
<div className={classNames('flex w-32 flex-col', isFirst && 'mt-5')}>
|
||||
<HotkeyField
|
||||
disabled={disabled}
|
||||
keys={definition.keys}
|
||||
modifierKeys={MODIFIER_KEYS}
|
||||
onChange={onChangeHandler}
|
||||
hotkeys={hotkeysModule}
|
||||
className="h-8 text-lg"
|
||||
/>
|
||||
{error && (
|
||||
<span className="p-2 text-left text-sm text-red-600">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
HotkeysPreferences.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
hotkeyDefinitions: PropTypes.object.isRequired,
|
||||
hotkeysModule: PropTypes.shape({
|
||||
initialize: PropTypes.func.isRequired,
|
||||
pause: PropTypes.func.isRequired,
|
||||
unpause: PropTypes.func.isRequired,
|
||||
startRecording: PropTypes.func.isRequired,
|
||||
record: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default HotkeysPreferences;
|
||||
@@ -0,0 +1,87 @@
|
||||
export const MODIFIER_KEYS = ['ctrl', 'alt', 'shift'];
|
||||
|
||||
export const DISALLOWED_COMBINATIONS = {
|
||||
'': [],
|
||||
alt: ['space'],
|
||||
shift: [],
|
||||
ctrl: [
|
||||
'f4',
|
||||
'f5',
|
||||
'f11',
|
||||
'w',
|
||||
'r',
|
||||
't',
|
||||
'o',
|
||||
'p',
|
||||
'a',
|
||||
'd',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'j',
|
||||
'l',
|
||||
'z',
|
||||
'x',
|
||||
'c',
|
||||
'v',
|
||||
'b',
|
||||
'n',
|
||||
'pagedown',
|
||||
'pageup',
|
||||
],
|
||||
'ctrl+shift': ['q', 'w', 'r', 't', 'p', 'a', 'h', 'v', 'b', 'n'],
|
||||
};
|
||||
|
||||
export const SPECIAL_KEYS = {
|
||||
8: 'backspace',
|
||||
9: 'tab',
|
||||
13: 'return',
|
||||
16: 'shift',
|
||||
17: 'ctrl',
|
||||
18: 'alt',
|
||||
19: 'pause',
|
||||
20: 'capslock',
|
||||
27: 'esc',
|
||||
32: 'space',
|
||||
33: 'pageup',
|
||||
34: 'pagedown',
|
||||
35: 'end',
|
||||
36: 'home',
|
||||
37: 'left',
|
||||
38: 'up',
|
||||
39: 'right',
|
||||
40: 'down',
|
||||
45: 'insert',
|
||||
46: 'del',
|
||||
96: '0',
|
||||
97: '1',
|
||||
98: '2',
|
||||
99: '3',
|
||||
100: '4',
|
||||
101: '5',
|
||||
102: '6',
|
||||
103: '7',
|
||||
104: '8',
|
||||
105: '9',
|
||||
106: '*',
|
||||
107: '+',
|
||||
109: '-',
|
||||
110: '.',
|
||||
111: '/',
|
||||
112: 'f1',
|
||||
113: 'f2',
|
||||
114: 'f3',
|
||||
115: 'f4',
|
||||
116: 'f5',
|
||||
117: 'f6',
|
||||
118: 'f7',
|
||||
119: 'f8',
|
||||
120: 'f9',
|
||||
121: 'f10',
|
||||
122: 'f11',
|
||||
123: 'f12',
|
||||
144: 'numlock',
|
||||
145: 'scroll',
|
||||
191: '/',
|
||||
224: 'meta',
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MODIFIER_KEYS, DISALLOWED_COMBINATIONS } from './hotkeysConfig';
|
||||
import i18n from 'i18next';
|
||||
|
||||
const formatPressedKeys = pressedKeysArray => pressedKeysArray.join('+');
|
||||
|
||||
const findConflictingCommand = (hotkeys, currentCommandName, pressedKeys) => {
|
||||
let firstConflictingCommand = undefined;
|
||||
const formatedPressedHotkeys = formatPressedKeys(pressedKeys);
|
||||
|
||||
for (const commandName in hotkeys) {
|
||||
const toolHotkeys = hotkeys[commandName].keys;
|
||||
const formatedToolHotkeys = formatPressedKeys(toolHotkeys);
|
||||
|
||||
if (formatedPressedHotkeys === formatedToolHotkeys && commandName !== currentCommandName) {
|
||||
firstConflictingCommand = hotkeys[commandName];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return firstConflictingCommand;
|
||||
};
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
MODIFIER: i18n.t('HotkeysValidators:It\'s not possible to define only modifier keys (ctrl, alt and shift) as a shortcut'),
|
||||
EMPTY: i18n.t('HotkeysValidators:Field can\'t be empty'),
|
||||
};
|
||||
|
||||
// VALIDATORS
|
||||
|
||||
const modifierValidator = ({ pressedKeys }) => {
|
||||
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
|
||||
// Check if it has a valid modifier
|
||||
const isModifier = MODIFIER_KEYS.includes(lastPressedKey);
|
||||
if (isModifier) {
|
||||
return { error: ERROR_MESSAGES.MODIFIER };
|
||||
}
|
||||
};
|
||||
|
||||
const emptyValidator = ({ pressedKeys = [] }) => {
|
||||
if (!pressedKeys.length) {
|
||||
return { error: ERROR_MESSAGES.EMPTY };
|
||||
}
|
||||
};
|
||||
|
||||
const conflictingValidator = ({ commandName, pressedKeys, hotkeys }) => {
|
||||
const conflictingCommand = findConflictingCommand(hotkeys, commandName, pressedKeys);
|
||||
|
||||
if (conflictingCommand) {
|
||||
return {
|
||||
error: i18n.t('HotkeysValidators:Hotkey is already in use', {action: conflictingCommand.label, pressedKeys: pressedKeys }),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const disallowedValidator = ({ pressedKeys = [] }) => {
|
||||
const lastPressedKey = pressedKeys[pressedKeys.length - 1];
|
||||
const modifierCommand = formatPressedKeys(pressedKeys.slice(0, pressedKeys.length - 1));
|
||||
|
||||
const disallowedCombination = DISALLOWED_COMBINATIONS[modifierCommand];
|
||||
const hasDisallowedCombinations = disallowedCombination
|
||||
? disallowedCombination.includes(lastPressedKey)
|
||||
: false;
|
||||
|
||||
if (hasDisallowedCombinations) {
|
||||
return {
|
||||
error: i18n.t('HotkeysValidators:Shortcut combination is not allowed', {pressedKeys: formatPressedKeys(pressedKeys)}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const hotkeysValidators = [
|
||||
emptyValidator,
|
||||
modifierValidator,
|
||||
conflictingValidator,
|
||||
disallowedValidator,
|
||||
];
|
||||
|
||||
export { hotkeysValidators };
|
||||
3
platform/ui/src/components/HotkeysPreferences/index.js
Normal file
3
platform/ui/src/components/HotkeysPreferences/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import HotkeysPreferences from './HotkeysPreferences.tsx';
|
||||
|
||||
export default HotkeysPreferences;
|
||||
47
platform/ui/src/components/HotkeysPreferences/utils.js
Normal file
47
platform/ui/src/components/HotkeysPreferences/utils.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { hotkeysValidators } from './hotkeysValidators';
|
||||
|
||||
/**
|
||||
* Split hotkeys definitions and create hotkey related tuples
|
||||
*
|
||||
* @param {array} hotkeyDefinitions
|
||||
* @returns {array} array of tuples consisted of command name and hotkey definition
|
||||
*/
|
||||
const splitHotkeyDefinitionsAndCreateTuples = hotkeyDefinitions => {
|
||||
const splitedHotkeys = [];
|
||||
const arrayHotkeys = Object.entries(hotkeyDefinitions);
|
||||
|
||||
if (arrayHotkeys.length) {
|
||||
const halfwayThrough = Math.ceil(arrayHotkeys.length / 2);
|
||||
splitedHotkeys.push(arrayHotkeys.slice(0, halfwayThrough));
|
||||
splitedHotkeys.push(arrayHotkeys.slice(halfwayThrough, arrayHotkeys.length));
|
||||
}
|
||||
|
||||
return splitedHotkeys;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a hotkey change
|
||||
*
|
||||
* @param {Object} arguments
|
||||
* @param {string} arguments.commandName command name or id
|
||||
* @param {array} arguments.pressedKeys new keys
|
||||
* @param {array} arguments.hotkeys current hotkeys
|
||||
* @returns {Object} {error} validation error
|
||||
*/
|
||||
const validate = ({ commandName, pressedKeys, hotkeys }) => {
|
||||
for (const validator of hotkeysValidators) {
|
||||
const validation = validator({
|
||||
commandName,
|
||||
pressedKeys,
|
||||
hotkeys,
|
||||
});
|
||||
|
||||
if (validation && validation.error) {
|
||||
return validation;
|
||||
}
|
||||
}
|
||||
|
||||
return { error: undefined };
|
||||
};
|
||||
|
||||
export { validate, splitHotkeyDefinitionsAndCreateTuples };
|
||||
14
platform/ui/src/components/Icon/Icon.tsx
Normal file
14
platform/ui/src/components/Icon/Icon.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import getIcon from './getIcon';
|
||||
|
||||
const Icon = ({ name, ...otherProps }) => {
|
||||
return <React.Fragment>{getIcon(name, { ...otherProps })}</React.Fragment>;
|
||||
};
|
||||
|
||||
Icon.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
97
platform/ui/src/components/Icon/__stories__/icon.stories.mdx
Normal file
97
platform/ui/src/components/Icon/__stories__/icon.stories.mdx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Icon from '../Icon';
|
||||
import { ICONS } from '../getIcon';
|
||||
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Icon,
|
||||
title: 'Components/Icon',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/Icon"
|
||||
component={Icon}
|
||||
/>
|
||||
|
||||
export const IconTemplate = args => (
|
||||
// Todo: Icon colors
|
||||
<div className="h-8 w-8">
|
||||
<Icon {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="Icon"
|
||||
componentRelativePath="Icon/Icon.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Icon is a component that renders the Icons.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
name: 'clipboard',
|
||||
}}
|
||||
>
|
||||
{IconTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Icon} />
|
||||
|
||||
## Usage
|
||||
|
||||
You can choose the icon from the list of icons. Here we have render all the icons.
|
||||
|
||||
<Canvas>
|
||||
<Story name="All Icons">
|
||||
{() => {
|
||||
const icons = Object.keys(ICONS);
|
||||
return (
|
||||
<div className="flex flex-wrap">
|
||||
{icons
|
||||
.filter(ic => ic !== 'magnifier')
|
||||
.map(icon => (
|
||||
<div className="m-4 flex flex-col items-center justify-center">
|
||||
<div class="h-8 w-8">
|
||||
<Icon name={icon} />
|
||||
</div>
|
||||
<div className="text-gray-600">{icon}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Color
|
||||
|
||||
Icon colors can be changed by wrapping the icon in a div with the class name
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Colors"
|
||||
args={{
|
||||
name: 'clipboard',
|
||||
className: 'text-red-700',
|
||||
}}
|
||||
>
|
||||
{IconTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Icon/__stories__/icon.stories.mdx" />
|
||||
452
platform/ui/src/components/Icon/getIcon.js
Normal file
452
platform/ui/src/components/Icon/getIcon.js
Normal file
@@ -0,0 +1,452 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ReactComponent as arrowDown } from './../../assets/icons/arrow-down.svg';
|
||||
import { ReactComponent as arrowLeft } from './../../assets/icons/arrow-left.svg';
|
||||
import { ReactComponent as arrowRight } from './../../assets/icons/arrow-right.svg';
|
||||
import { ReactComponent as arrowLeftSmall } from './../../assets/icons/arrow-left-small.svg';
|
||||
import { ReactComponent as arrowRightSmall } from './../../assets/icons/arrow-right-small.svg';
|
||||
import { ReactComponent as calendar } from './../../assets/icons/calendar.svg';
|
||||
import { ReactComponent as cancel } from './../../assets/icons/cancel.svg';
|
||||
import { ReactComponent as clipboard } from './../../assets/icons/clipboard.svg';
|
||||
import { ReactComponent as close } from './../../assets/icons/closeIcon.svg';
|
||||
import { ReactComponent as database } from './../../assets/icons/database.svg';
|
||||
import { ReactComponent as dottedCircle } from './../../assets/icons/dotted-circle.svg';
|
||||
import { ReactComponent as circledCheckmark } from './../../assets/icons/circled-checkmark.svg';
|
||||
import { ReactComponent as chevronDown } from './../../assets/icons/chevron-down.svg';
|
||||
import { ReactComponent as chevronLeft } from './../../assets/icons/chevron-left.svg';
|
||||
import { ReactComponent as chevronMenu } from './../../assets/icons/chevron-menu.svg';
|
||||
import { ReactComponent as chevronNext } from './../../assets/icons/chevron-next.svg';
|
||||
import { ReactComponent as chevronPrev } from './../../assets/icons/chevron-prev.svg';
|
||||
import { ReactComponent as chevronRight } from './../../assets/icons/chevron-right.svg';
|
||||
import { ReactComponent as contentNext } from './../../assets/icons/content-next.svg';
|
||||
import { ReactComponent as contentPrev } from './../../assets/icons/content-prev.svg';
|
||||
import { ReactComponent as eyeVisible } from './../../assets/icons/eye-visible.svg';
|
||||
import { ReactComponent as eyeHidden } from './../../assets/icons/eye-hidden.svg';
|
||||
import { ReactComponent as exclamation } from './../../assets/icons/exclamation.svg';
|
||||
import { ReactComponent as externalLink } from './../../assets/icons/external-link.svg';
|
||||
import { ReactComponent as groupLayers } from './../../assets/icons/group-layers.svg';
|
||||
import { ReactComponent as info } from './../../assets/icons/info.svg';
|
||||
import { ReactComponent as infoAction } from './../../assets/icons/info-action.svg';
|
||||
import { ReactComponent as infoLink } from './../../assets/icons/info-link.svg';
|
||||
import { ReactComponent as launchArrow } from './../../assets/icons/launch-arrow.svg';
|
||||
import { ReactComponent as launchInfo } from './../../assets/icons/launch-info.svg';
|
||||
import { ReactComponent as link } from './../../assets/icons/tool-stack-image-sync.svg';
|
||||
import { ReactComponent as listBullets } from './../../assets/icons/list-bullets.svg';
|
||||
import { ReactComponent as lock } from './../../assets/icons/lock.svg';
|
||||
import { ReactComponent as logoOhifSmall } from './../../assets/icons/logo-ohif-small.svg';
|
||||
import { ReactComponent as logoDarkBackGround } from './../../assets/icons/ohif-logo-color-darkbg.svg';
|
||||
import { ReactComponent as magnifier } from './../../assets/icons/magnifier.svg';
|
||||
import { ReactComponent as notificationwarningDiamond } from './../../assets/icons/notificationwarning-diamond.svg';
|
||||
import { ReactComponent as pencil } from './../../assets/icons/pencil.svg';
|
||||
import { ReactComponent as powerOff } from './../../assets/icons/power-off.svg';
|
||||
import { ReactComponent as profile } from './../../assets/icons/profile.svg';
|
||||
import { ReactComponent as pushLeft } from './../../assets/icons/push-left.svg';
|
||||
import { ReactComponent as pushRight } from './../../assets/icons/push-right.svg';
|
||||
import { ReactComponent as settings } from './../../assets/icons/settings.svg';
|
||||
import { ReactComponent as sidePanelCloseLeft } from './../../assets/icons/side-panel-close-left.svg';
|
||||
import { ReactComponent as sidePanelCloseRight } from './../../assets/icons/side-panel-close-right.svg';
|
||||
import { ReactComponent as sorting } from './../../assets/icons/sorting.svg';
|
||||
import { ReactComponent as sortingActiveDown } from './../../assets/icons/sorting-active-down.svg';
|
||||
import { ReactComponent as sortingActiveUp } from './../../assets/icons/sorting-active-up.svg';
|
||||
import { ReactComponent as statusAlertWarning } from './../../assets/icons/status-alert-warning.svg';
|
||||
import { ReactComponent as statusAlert } from './../../assets/icons/status-alert.svg';
|
||||
import { ReactComponent as statusLocked } from './../../assets/icons/status-locked.svg';
|
||||
import { ReactComponent as statusTracked } from './../../assets/icons/status-tracked.svg';
|
||||
import { ReactComponent as statusUntracked } from './../../assets/icons/status-untracked.svg';
|
||||
import { ReactComponent as tracked } from './../../assets/icons/tracked.svg';
|
||||
import { ReactComponent as unlink } from './../../assets/icons/unlink.svg';
|
||||
import { ReactComponent as checkboxChecked } from './../../assets/icons/checkbox-checked.svg';
|
||||
import { ReactComponent as checkboxUnchecked } from './../../assets/icons/checkbox-unchecked.svg';
|
||||
import { ReactComponent as iconAlertOutline } from './../../assets/icons/icons-alert-outline.svg';
|
||||
import { ReactComponent as iconAlertSmall } from './../../assets/icons/icon-alert-small.svg';
|
||||
import { ReactComponent as iconClose } from './../../assets/icons/icon-close.svg';
|
||||
import { ReactComponent as iconClearField } from './../../assets/icons/icon-clear-field.svg';
|
||||
import { ReactComponent as iconNextInactive } from './../../assets/icons/icon-next-inactive.svg';
|
||||
import { ReactComponent as iconNext } from './../../assets/icons/icon-next.svg';
|
||||
import { ReactComponent as iconPlay } from './../../assets/icons/icon-play.svg';
|
||||
import { ReactComponent as iconPause } from './../../assets/icons/icon-pause.svg';
|
||||
import { ReactComponent as iconPrevInactive } from './../../assets/icons/icon-prev-inactive.svg';
|
||||
import { ReactComponent as iconPrev } from './../../assets/icons/icon-prev.svg';
|
||||
import { ReactComponent as iconSearch } from './../../assets/icons/icon-search.svg';
|
||||
import { ReactComponent as iconStatusAlert } from './../../assets/icons/icon-status-alert.svg';
|
||||
import { ReactComponent as iconTransferring } from './../../assets/icons/icon-transferring.svg';
|
||||
import { ReactComponent as iconUpload } from './../../assets/icons/icon-upload.svg';
|
||||
import { ReactComponent as navigationPanelRightHide } from './../../assets/icons/navigation-panel-right-hide.svg';
|
||||
import { ReactComponent as navigationPanelRightReveal } from './../../assets/icons/navigation-panel-right-reveal.svg';
|
||||
import { ReactComponent as tabLinear } from './../../assets/icons/tab-linear.svg';
|
||||
import { ReactComponent as tabPatientInfo } from './../../assets/icons/tab-patient-info.svg';
|
||||
import { ReactComponent as tabROIThreshold } from './../../assets/icons/tab-roi-threshold.svg';
|
||||
import { ReactComponent as tabSegmentation } from './../../assets/icons/tab-segmentation.svg';
|
||||
import { ReactComponent as tabStudies } from './../../assets/icons/tab-studies.svg';
|
||||
import { ReactComponent as uiArrowDown } from './../../assets/icons/ui-arrow-down.svg';
|
||||
import { ReactComponent as uiArrowUp } from './../../assets/icons/ui-arrow-up.svg';
|
||||
import { ReactComponent as uiArrowLeft } from './../../assets/icons/ui-arrow-left.svg';
|
||||
import { ReactComponent as uiArrowRight } from './../../assets/icons/ui-arrow-right.svg';
|
||||
import { ReactComponent as loadingOHIFMark } from './../../assets/icons/loading-ohif-mark.svg';
|
||||
import { ReactComponent as notificationsInfo } from './../../assets/icons/notifications-info.svg';
|
||||
import { ReactComponent as notificationsWarning } from './../../assets/icons/notifications-warning.svg';
|
||||
import { ReactComponent as notificationsError } from './../../assets/icons/notifications-error.svg';
|
||||
import { ReactComponent as notificationsSuccess } from './../../assets/icons/notifications-success.svg';
|
||||
import { ReactComponent as nextArrow } from './../../assets/icons/next-arrow.svg';
|
||||
import { ReactComponent as prevArrow } from './../../assets/icons/prev-arrow.svg';
|
||||
import { ReactComponent as viewportStatusTracked } from './../../assets/icons/viewport-status-tracked.svg';
|
||||
import { ReactComponent as toggleDicomOverlay } from './../../assets/icons/tool-toggle-dicom-overlay.svg';
|
||||
import { ReactComponent as toolZoom } from './../../assets/icons/tool-zoom.svg';
|
||||
import { ReactComponent as toolCapture } from './../../assets/icons/tool-capture.svg';
|
||||
import { ReactComponent as toolLayout } from './../../assets/icons/tool-layout-default.svg';
|
||||
import { ReactComponent as toolMore } from './../../assets/icons/tool-more-menu.svg';
|
||||
import { ReactComponent as toolMove } from './../../assets/icons/tool-move.svg';
|
||||
import { ReactComponent as toolWindow } from './../../assets/icons/tool-window-level.svg';
|
||||
import { ReactComponent as toolAnnotate } from './../../assets/icons/tool-annotate.svg';
|
||||
import { ReactComponent as toolBidirectional } from './../../assets/icons/tool-bidirectional.svg';
|
||||
import { ReactComponent as toolElipse } from './../../assets/icons/tool-measure-elipse.svg';
|
||||
import { ReactComponent as toolCircle } from './../../assets/icons/tool-circle.svg';
|
||||
import { ReactComponent as toolLength } from './../../assets/icons/tool-length.svg';
|
||||
import { ReactComponent as toolStackScroll } from './../../assets/icons/tool-stack-scroll.svg';
|
||||
import { ReactComponent as toolMagnify } from './../../assets/icons/tool-quick-magnify.svg';
|
||||
import { ReactComponent as toolFlipHorizontal } from './../../assets/icons/tool-flip-horizontal.svg';
|
||||
import { ReactComponent as toolInvert } from './../../assets/icons/tool-invert.svg';
|
||||
import { ReactComponent as toolRotateRight } from './../../assets/icons/tool-rotate-right.svg';
|
||||
import { ReactComponent as toolCine } from './../../assets/icons/tool-cine.svg';
|
||||
import { ReactComponent as toolCrosshair } from './../../assets/icons/tool-crosshair.svg';
|
||||
import { ReactComponent as toolProbe } from './../../assets/icons/focus-frame-target.svg';
|
||||
import { ReactComponent as toolAngle } from './../../assets/icons/tool-angle.svg';
|
||||
import { ReactComponent as toolReset } from './../../assets/icons/tool-reset.svg';
|
||||
import { ReactComponent as toolRectangle } from './../../assets/icons/tool-rectangle.svg';
|
||||
import { ReactComponent as toolFusionColor } from './../../assets/icons/tool-fusion-color.svg';
|
||||
import { ReactComponent as toolCreateThreshold } from './../../assets/icons/tool-create-threshold.svg';
|
||||
import { ReactComponent as toolCalibration } from './../../assets/icons/tool-calibrate.svg';
|
||||
import { ReactComponent as toolFreehand } from './../../assets/icons/tool-freehand.svg';
|
||||
import { ReactComponent as toolFreehandPolygon } from './../../assets/icons/tool-freehand-polygon.svg';
|
||||
import { ReactComponent as toolPolygon } from './../../assets/icons/tool-polygon.svg';
|
||||
import { ReactComponent as editPatient } from './../../assets/icons/edit-patient.svg';
|
||||
import { ReactComponent as panelGroupMore } from './../../assets/icons/panel-group-more.svg';
|
||||
import { ReactComponent as panelGroupOpenClose } from './../../assets/icons/panel-group-open-close.svg';
|
||||
import { ReactComponent as rowAdd } from './../../assets/icons/row-add.svg';
|
||||
import { ReactComponent as rowEdit } from './../../assets/icons/row-edit.svg';
|
||||
import { ReactComponent as rowHidden } from './../../assets/icons/row-hidden.svg';
|
||||
import { ReactComponent as rowShown } from './../../assets/icons/row-shown.svg';
|
||||
import { ReactComponent as rowLock } from './../../assets/icons/row-lock.svg';
|
||||
import { ReactComponent as rowUnlock } from './../../assets/icons/row-unlock.svg';
|
||||
import { ReactComponent as iconMPR } from './../../assets/icons/icon-mpr-alt.svg';
|
||||
import { ReactComponent as checkboxDefault } from './../../assets/icons/checkbox-default.svg';
|
||||
import { ReactComponent as checkboxActive } from './../../assets/icons/checkbox-active.svg';
|
||||
import { ReactComponent as referenceLines } from './../../assets/icons/tool-reference-lines.svg';
|
||||
import { ReactComponent as chevronDownNew } from './../../assets/icons/icon-disclosure-close.svg';
|
||||
import { ReactComponent as chevronLeftNew } from './../../assets/icons/icon-disclosure-open.svg';
|
||||
import { ReactComponent as settingsBars } from './../../assets/icons/icon-display-settings.svg';
|
||||
import { ReactComponent as iconAdd } from './../../assets/icons/icon-add.svg';
|
||||
import { ReactComponent as iconRename } from './../../assets/icons/icon-rename.svg';
|
||||
import { ReactComponent as iconDelete } from './../../assets/icons/icon-delete.svg';
|
||||
import { ReactComponent as iconMoreMenu } from './../../assets/icons/icon-more-menu.svg';
|
||||
import { ReactComponent as iconToolBrush } from './../../assets/icons/tool-seg-brush.svg';
|
||||
import { ReactComponent as iconToolEraser } from './../../assets/icons/tool-seg-eraser.svg';
|
||||
import { ReactComponent as iconToolScissor } from './../../assets/icons/icon-tool-scissor.svg';
|
||||
import { ReactComponent as iconToolShape } from './../../assets/icons/tool-seg-shape.svg';
|
||||
import { ReactComponent as iconToolThreshold } from './../../assets/icons/tool-seg-threshold.svg';
|
||||
import { ReactComponent as viewportWindowLevel } from './../../assets/icons/viewport-window-level.svg';
|
||||
import { ReactComponent as dicomTagBrowser } from './../../assets/icons/tool-dicom-tag-browser.svg';
|
||||
import { ReactComponent as iconToolFreehandRoi } from './../../assets/icons/tool-freehand-roi.svg';
|
||||
import { ReactComponent as iconToolLivewire } from './../../assets/icons/tool-magnetic-roi.svg';
|
||||
import { ReactComponent as iconToolSplineRoi } from './../../assets/icons/tool-spline-roi.svg';
|
||||
import { ReactComponent as iconToolUltrasoundBidirectional } from './../../assets/icons/tool-ultrasound-bidirectional.svg';
|
||||
import { ReactComponent as iconToolLoupe } from './../../assets/icons/tool-magnify.svg';
|
||||
import { ReactComponent as oldTrash } from './../../assets/icons/old-trash.svg';
|
||||
import { ReactComponent as oldPlay } from './../../assets/icons/old-play.svg';
|
||||
import { ReactComponent as oldStop } from './../../assets/icons/old-stop.svg';
|
||||
import { ReactComponent as iconColorLUT } from './../../assets/icons/icon-color-lut.svg';
|
||||
import { ReactComponent as iconChevronPatient } from './../../assets/icons/icon-chevron-patient.svg';
|
||||
import { ReactComponent as iconPatient } from './../../assets/icons/icon-patient.svg';
|
||||
import { ReactComponent as iconSettings } from './../../assets/icons/icon-settings.svg';
|
||||
import { ReactComponent as iconToolbarBack } from './../../assets/icons/icon-toolbar-back.svg';
|
||||
import { ReactComponent as iconMultiplePatients } from './../../assets/icons/icon-multiple-patients.svg';
|
||||
import { ReactComponent as layoutAdvanced3DFourUp } from './../../assets/icons/layout-advanced-3d-four-up.svg';
|
||||
import { ReactComponent as layoutAdvanced3DMain } from './../../assets/icons/layout-advanced-3d-main.svg';
|
||||
import { ReactComponent as layoutAdvanced3DOnly } from './../../assets/icons/layout-advanced-3d-only.svg';
|
||||
import { ReactComponent as layoutAdvanced3DPrimary } from './../../assets/icons/layout-advanced-3d-primary.svg';
|
||||
import { ReactComponent as layoutAdvancedAxialPrimary } from './../../assets/icons/layout-advanced-axial-primary.svg';
|
||||
import { ReactComponent as layoutAdvancedMPR } from './../../assets/icons/layout-advanced-mpr.svg';
|
||||
import { ReactComponent as layoutCommon1x1 } from './../../assets/icons/layout-common-1x1.svg';
|
||||
import { ReactComponent as layoutCommon1x2 } from './../../assets/icons/layout-common-1x2.svg';
|
||||
import { ReactComponent as layoutCommon2x2 } from './../../assets/icons/layout-common-2x2.svg';
|
||||
import { ReactComponent as layoutCommon2x3 } from './../../assets/icons/layout-common-2x3.svg';
|
||||
import { ReactComponent as iconToolRotate } from './../../assets/icons/tool-3d-rotate.svg';
|
||||
import { ReactComponent as tab4D } from './../../assets/icons/tab-4d.svg';
|
||||
import { ReactComponent as investigationalUse } from './../../assets/icons/illustration-investigational-use.svg';
|
||||
import { ReactComponent as actionNewDialog } from './../../assets/icons/action-new-dialog.svg';
|
||||
import { ReactComponent as iconToolCobbAngle } from './../../assets/icons/tool-cobb-angle.svg';
|
||||
import { ReactComponent as iconToolWindowRegion } from './../../assets/icons/tool-window-region.svg';
|
||||
|
||||
import CTAAA from './../../assets/icons/CT-AAA.png';
|
||||
import CTAAA2 from './../../assets/icons/CT-AAA2.png';
|
||||
import CTAir from './../../assets/icons/CT-Air.png';
|
||||
import CTBone from './../../assets/icons/CT-Bone.png';
|
||||
import CTBones from './../../assets/icons/CT-Bones.png';
|
||||
import CTCardiac from './../../assets/icons/CT-Cardiac.png';
|
||||
import CTCardiac2 from './../../assets/icons/CT-Cardiac2.png';
|
||||
import CTCardiac3 from './../../assets/icons/CT-Cardiac3.png';
|
||||
import CTChestContrastEnhanced from './../../assets/icons/CT-Chest-Contrast-Enhanced.png';
|
||||
import CTChestVessels from './../../assets/icons/CT-Chest-Vessels.png';
|
||||
import CTCoronaryArteries from './../../assets/icons/CT-Coronary-Arteries.png';
|
||||
import CTCoronaryArteries2 from './../../assets/icons/CT-Coronary-Arteries-2.png';
|
||||
import CTCoronaryArteries3 from './../../assets/icons/CT-Coronary-Arteries-3.png';
|
||||
import CTCroppedVolumeBone from './../../assets/icons/CT-Cropped-Volume-Bone.png';
|
||||
import CTFat from './../../assets/icons/CT-Fat.png';
|
||||
import CTLiverVasculature from './../../assets/icons/CT-Liver-Vasculature.png';
|
||||
import CTLung from './../../assets/icons/CT-Lung.png';
|
||||
import CTMIP from './../../assets/icons/CT-MIP.png';
|
||||
import CTMuscle from './../../assets/icons/CT-Muscle.png';
|
||||
import CTPulmonaryArteries from './../../assets/icons/CT-Pulmonary-Arteries.png';
|
||||
import CTSoftTissue from './../../assets/icons/CT-Soft-Tissue.png';
|
||||
import DTIFABrain from './../../assets/icons/DTI-FA-Brain.png';
|
||||
import MRAngio from './../../assets/icons/MR-Angio.png';
|
||||
import MRDefault from './../../assets/icons/MR-Default.png';
|
||||
import MRMIP from './../../assets/icons/MR-MIP.png';
|
||||
import MRT2Brain from './../../assets/icons/MR-T2-Brain.png';
|
||||
import VolumeRendering from './../../assets/icons/VolumeRendering.png';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-down': arrowDown,
|
||||
'arrow-left': arrowLeft,
|
||||
'arrow-right': arrowRight,
|
||||
'arrow-left-small': arrowLeftSmall,
|
||||
'arrow-right-small': arrowRightSmall,
|
||||
calendar: calendar,
|
||||
cancel: cancel,
|
||||
clipboard: clipboard,
|
||||
close: close,
|
||||
database: database,
|
||||
'dotted-circle': dottedCircle,
|
||||
'circled-checkmark': circledCheckmark,
|
||||
'chevron-down': chevronDown,
|
||||
'chevron-left': chevronLeft,
|
||||
'chevron-menu': chevronMenu,
|
||||
'chevron-next': chevronNext,
|
||||
'chevron-prev': chevronPrev,
|
||||
'chevron-right': chevronRight,
|
||||
'content-next': contentNext,
|
||||
'content-prev': contentPrev,
|
||||
'eye-visible': eyeVisible,
|
||||
'eye-hidden': eyeHidden,
|
||||
'external-link': externalLink,
|
||||
'group-layers': groupLayers,
|
||||
info: info,
|
||||
'icon-alert-outline': iconAlertOutline,
|
||||
'icon-alert-small': iconAlertSmall,
|
||||
'icon-clear-field': iconClearField,
|
||||
'icon-close': iconClose,
|
||||
'icon-play': iconPlay,
|
||||
'icon-pause': iconPause,
|
||||
'icon-search': iconSearch,
|
||||
'icon-status-alert': iconStatusAlert,
|
||||
'icon-transferring': iconTransferring,
|
||||
'info-action': infoAction,
|
||||
'info-link': infoLink,
|
||||
'launch-arrow': launchArrow,
|
||||
'launch-info': launchInfo,
|
||||
link: link,
|
||||
'list-bullets': listBullets,
|
||||
lock: lock,
|
||||
'logo-ohif-small': logoOhifSmall,
|
||||
'logo-dark-background': logoDarkBackGround,
|
||||
magnifier: magnifier,
|
||||
exclamation: exclamation,
|
||||
'notificationwarning-diamond': notificationwarningDiamond,
|
||||
pencil: pencil,
|
||||
'power-off': powerOff,
|
||||
profile: profile,
|
||||
'push-left': pushLeft,
|
||||
'push-right': pushRight,
|
||||
settings: settings,
|
||||
'side-panel-close-left': sidePanelCloseLeft,
|
||||
'side-panel-close-right': sidePanelCloseRight,
|
||||
'sorting-active-down': sortingActiveDown,
|
||||
'sorting-active-up': sortingActiveUp,
|
||||
'status-alert': statusAlert,
|
||||
'status-alert-warning': statusAlertWarning,
|
||||
'status-locked': statusLocked,
|
||||
'status-tracked': statusTracked,
|
||||
'status-untracked': statusUntracked,
|
||||
sorting: sorting,
|
||||
tracked: tracked,
|
||||
unlink: unlink,
|
||||
'panel-group-more': panelGroupMore,
|
||||
'panel-group-open-close': panelGroupOpenClose,
|
||||
'row-add': rowAdd,
|
||||
'row-edit': rowEdit,
|
||||
'row-hidden': rowHidden,
|
||||
'row-shown': rowShown,
|
||||
'row-lock': rowLock,
|
||||
'row-unlock': rowUnlock,
|
||||
'checkbox-checked': checkboxChecked,
|
||||
'checkbox-unchecked': checkboxUnchecked,
|
||||
'loading-ohif-mark': loadingOHIFMark,
|
||||
'notifications-info': notificationsInfo,
|
||||
'notifications-error': notificationsError,
|
||||
'notifications-success': notificationsSuccess,
|
||||
'notifications-warning': notificationsWarning,
|
||||
|
||||
/** Tools */
|
||||
'toggle-dicom-overlay': toggleDicomOverlay,
|
||||
'tool-zoom': toolZoom,
|
||||
'tool-capture': toolCapture,
|
||||
'tool-layout': toolLayout,
|
||||
'tool-more-menu': toolMore,
|
||||
'tool-move': toolMove,
|
||||
'tool-window-level': toolWindow,
|
||||
'tool-annotate': toolAnnotate,
|
||||
'tool-bidirectional': toolBidirectional,
|
||||
'tool-ellipse': toolElipse,
|
||||
'tool-circle': toolCircle,
|
||||
'tool-length': toolLength,
|
||||
'tool-stack-scroll': toolStackScroll,
|
||||
'tool-magnify': toolMagnify,
|
||||
'tool-flip-horizontal': toolFlipHorizontal,
|
||||
'tool-invert': toolInvert,
|
||||
'tool-rotate-right': toolRotateRight,
|
||||
'tool-cine': toolCine,
|
||||
'tool-crosshair': toolCrosshair,
|
||||
'tool-probe': toolProbe,
|
||||
'tool-angle': toolAngle,
|
||||
'tool-reset': toolReset,
|
||||
'tool-rectangle': toolRectangle,
|
||||
'tool-fusion-color': toolFusionColor,
|
||||
'tool-create-threshold': toolCreateThreshold,
|
||||
'tool-calibration': toolCalibration,
|
||||
'tool-point': toolCircle,
|
||||
'tool-freehand-line': toolFreehand,
|
||||
'tool-freehand-polygon': toolFreehandPolygon,
|
||||
'tool-polygon': toolPolygon,
|
||||
'tool-3d-rotate': iconToolRotate,
|
||||
'edit-patient': editPatient,
|
||||
'icon-mpr': iconMPR,
|
||||
'icon-next-inactive': iconNextInactive,
|
||||
'icon-next': iconNext,
|
||||
'icon-prev-inactive': iconPrevInactive,
|
||||
'icon-prev': iconPrev,
|
||||
'icon-upload': iconUpload,
|
||||
'navigation-panel-right-hide': navigationPanelRightHide,
|
||||
'navigation-panel-right-reveal': navigationPanelRightReveal,
|
||||
'tab-linear': tabLinear,
|
||||
'tab-patient-info': tabPatientInfo,
|
||||
'tab-roi-threshold': tabROIThreshold,
|
||||
'tab-segmentation': tabSegmentation,
|
||||
'tab-studies': tabStudies,
|
||||
'ui-arrow-down': uiArrowDown,
|
||||
'ui-arrow-up': uiArrowUp,
|
||||
'ui-arrow-left': uiArrowLeft,
|
||||
'ui-arrow-right': uiArrowRight,
|
||||
'checkbox-default': checkboxDefault,
|
||||
'checkbox-active': checkboxActive,
|
||||
'tool-referenceLines': referenceLines,
|
||||
'chevron-left-new': chevronLeftNew,
|
||||
'chevron-down-new': chevronDownNew,
|
||||
'settings-bars': settingsBars,
|
||||
'icon-rename': iconRename,
|
||||
'icon-add': iconAdd,
|
||||
'icon-delete': iconDelete,
|
||||
'icon-more-menu': iconMoreMenu,
|
||||
'icon-tool-brush': iconToolBrush,
|
||||
'icon-tool-eraser': iconToolEraser,
|
||||
'icon-tool-scissor': iconToolScissor,
|
||||
'icon-tool-shape': iconToolShape,
|
||||
'icon-tool-threshold': iconToolThreshold,
|
||||
'next-arrow': nextArrow,
|
||||
'prev-arrow': prevArrow,
|
||||
'viewport-status-tracked': viewportStatusTracked,
|
||||
'viewport-window-level': viewportWindowLevel,
|
||||
'dicom-tag-browser': dicomTagBrowser,
|
||||
/** New Tools */
|
||||
'icon-tool-freehand-roi': iconToolFreehandRoi,
|
||||
'icon-tool-livewire': iconToolLivewire,
|
||||
'icon-tool-spline-roi': iconToolSplineRoi,
|
||||
'icon-tool-ultrasound-bidirectional': iconToolUltrasoundBidirectional,
|
||||
'icon-tool-loupe': iconToolLoupe,
|
||||
'icon-tool-cobb-angle': iconToolCobbAngle,
|
||||
'icon-tool-window-region': iconToolWindowRegion,
|
||||
/** Old OHIF */
|
||||
'old-trash': oldTrash,
|
||||
'old-play': oldPlay,
|
||||
'old-stop': oldStop,
|
||||
/** ColorLut */
|
||||
'icon-color-lut': iconColorLUT,
|
||||
/** New Patient Info Toolbar */
|
||||
'icon-chevron-patient': iconChevronPatient,
|
||||
'icon-patient': iconPatient,
|
||||
'icon-settings': iconSettings,
|
||||
'icon-toolbar-back': iconToolbarBack,
|
||||
'icon-multiple-patients': iconMultiplePatients,
|
||||
/** Volume Rendering */
|
||||
'CT-AAA': CTAAA,
|
||||
'CT-AAA2': CTAAA2,
|
||||
'CT-Air': CTAir,
|
||||
'CT-Bone': CTBone,
|
||||
'CT-Bones': CTBones,
|
||||
'CT-Cardiac': CTCardiac,
|
||||
'CT-Cardiac2': CTCardiac2,
|
||||
'CT-Cardiac3': CTCardiac3,
|
||||
'CT-Chest-Contrast-Enhanced': CTChestContrastEnhanced,
|
||||
'CT-Chest-Vessels': CTChestVessels,
|
||||
'CT-Coronary-Arteries': CTCoronaryArteries,
|
||||
'CT-Coronary-Arteries-2': CTCoronaryArteries2,
|
||||
'CT-Coronary-Arteries-3': CTCoronaryArteries3,
|
||||
'CT-Cropped-Volume-Bone': CTCroppedVolumeBone,
|
||||
'CT-Fat': CTFat,
|
||||
'CT-Liver-Vasculature': CTLiverVasculature,
|
||||
'CT-Lung': CTLung,
|
||||
'CT-MIP': CTMIP,
|
||||
'CT-Muscle': CTMuscle,
|
||||
'CT-Pulmonary-Arteries': CTPulmonaryArteries,
|
||||
'CT-Soft-Tissue': CTSoftTissue,
|
||||
'DTI-FA-Brain': DTIFABrain,
|
||||
'MR-Angio': MRAngio,
|
||||
'MR-Default': MRDefault,
|
||||
'MR-MIP': MRMIP,
|
||||
'MR-T2-Brain': MRT2Brain,
|
||||
VolumeRendering: VolumeRendering,
|
||||
'action-new-dialog': actionNewDialog,
|
||||
/** LAYOUT */
|
||||
'layout-advanced-3d-four-up': layoutAdvanced3DFourUp,
|
||||
'layout-advanced-3d-main': layoutAdvanced3DMain,
|
||||
'layout-advanced-3d-only': layoutAdvanced3DOnly,
|
||||
'layout-advanced-3d-primary': layoutAdvanced3DPrimary,
|
||||
'layout-advanced-axial-primary': layoutAdvancedAxialPrimary,
|
||||
'layout-advanced-mpr': layoutAdvancedMPR,
|
||||
'layout-common-1x1': layoutCommon1x1,
|
||||
'layout-common-1x2': layoutCommon1x2,
|
||||
'layout-common-2x2': layoutCommon2x2,
|
||||
'layout-common-2x3': layoutCommon2x3,
|
||||
'tab-4d': tab4D,
|
||||
|
||||
/** New investigational use */
|
||||
'illustration-investigational-use': investigationalUse,
|
||||
};
|
||||
|
||||
function addIcon(iconName, iconSVG) {
|
||||
if (ICONS[iconName]) {
|
||||
console.warn(`Icon ${iconName} already exists.`);
|
||||
}
|
||||
|
||||
ICONS[iconName] = iconSVG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the matching SVG Icon as a React Component.
|
||||
* Results in an inlined SVG Element. If there's no match,
|
||||
* return `null`
|
||||
*/
|
||||
export default function getIcon(key, props) {
|
||||
const icon = ICONS[key];
|
||||
|
||||
if (!key || !icon) {
|
||||
return React.createElement('div', null, 'Missing Icon');
|
||||
}
|
||||
|
||||
if (typeof icon === 'string' && icon.endsWith('.png')) {
|
||||
return React.createElement('img', { src: icon, ...props });
|
||||
} else {
|
||||
return React.createElement(icon, props);
|
||||
}
|
||||
}
|
||||
|
||||
export { getIcon, ICONS, addIcon };
|
||||
2
platform/ui/src/components/Icon/index.js
Normal file
2
platform/ui/src/components/Icon/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import Icon from './Icon';
|
||||
export default Icon;
|
||||
26
platform/ui/src/components/IconButton/IconButton.stories.tsx
Normal file
26
platform/ui/src/components/IconButton/IconButton.stories.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import IconButton from './IconButton';
|
||||
import Icon from '../Icon/Icon';
|
||||
import { ICONS } from '../Icon/getIcon';
|
||||
|
||||
export default {
|
||||
component: IconButton,
|
||||
title: 'Icons/IconButton',
|
||||
argTypes: {
|
||||
iconName: {
|
||||
control: { type: 'select', options: Object.keys(ICONS) },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = ({ iconName, ...args }) => (
|
||||
<IconButton {...args}>
|
||||
<Icon name={iconName} />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
export const Default = Template.bind({});
|
||||
|
||||
Default.args = {
|
||||
iconName: 'clipboard',
|
||||
};
|
||||
139
platform/ui/src/components/IconButton/IconButton.tsx
Normal file
139
platform/ui/src/components/IconButton/IconButton.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const baseClasses =
|
||||
'text-center items-center justify-center transition duration-300 ease-in-out outline-none font-bold focus:outline-none';
|
||||
|
||||
const roundedClasses = {
|
||||
none: '',
|
||||
small: 'rounded',
|
||||
medium: 'rounded-md',
|
||||
large: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
const disabledClasses = {
|
||||
true: 'ohif-disabled',
|
||||
false: '',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
text: {
|
||||
default:
|
||||
'text-white hover:bg-primary-light hover:text-black active:opacity-80 focus:!bg-primary-light focus:text-black',
|
||||
primary:
|
||||
'text-primary-main hover:bg-primary-main hover:text-white active:opacity-80 focus:bg-primary-main focus:text-white',
|
||||
secondary:
|
||||
'text-secondary-light hover:bg-secondary-light hover:text-white active:opacity-80 focus:bg-secondary-light focus:text-white',
|
||||
white:
|
||||
'text-white hover:bg-white hover:text-black active:opacity-80 focus:bg-white focus:text-black',
|
||||
black:
|
||||
'text-black hover:bg-black hover:text-white focus:bg-black focus:text-white active:opacity-80',
|
||||
},
|
||||
outlined: {
|
||||
default:
|
||||
'border border-primary-light text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
primary:
|
||||
'border border-primary-main text-primary-main hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
secondary:
|
||||
'border border-secondary-light text-secondary-light hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
white: 'border border-white text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
black:
|
||||
'border border-primary-main text-white hover:bg-primary-main focus:bg-primary-main hover:border-black focus:border-black',
|
||||
},
|
||||
contained: {
|
||||
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',
|
||||
black: 'text-white hover:opacity-80 active:opacity-100 focus:opacity-80',
|
||||
},
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'py-2 px-2 text-base',
|
||||
medium: 'py-3 px-3 text-lg',
|
||||
large: 'py-4 px-4 text-xl',
|
||||
initial: '',
|
||||
toolbar: 'text-lg',
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
small: 'w-4 h-4',
|
||||
medium: 'w-5 h-5',
|
||||
large: 'w-6 h-6',
|
||||
toolbar: 'w-[28px] h-[28px]',
|
||||
toolbox: 'w-[24px] h-[24px]',
|
||||
};
|
||||
|
||||
const fullWidthClasses = {
|
||||
true: 'flex w-full',
|
||||
false: 'inline-flex',
|
||||
};
|
||||
|
||||
const IconButton = ({
|
||||
children,
|
||||
variant = 'contained',
|
||||
color = 'default',
|
||||
size = 'medium',
|
||||
rounded = 'medium',
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
fullWidth = false,
|
||||
onClick = () => {},
|
||||
className,
|
||||
id,
|
||||
...rest
|
||||
}) => {
|
||||
const buttonElement = useRef(null);
|
||||
|
||||
const handleOnClick = e => {
|
||||
buttonElement.current.blur();
|
||||
onClick(e);
|
||||
};
|
||||
|
||||
const padding = size === 'toolbar' ? '6px' : size === 'toolbox' ? '4px' : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classnames(
|
||||
baseClasses,
|
||||
variantClasses[variant][color],
|
||||
roundedClasses[rounded],
|
||||
sizeClasses[size],
|
||||
fullWidthClasses[fullWidth],
|
||||
disabledClasses[disabled],
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
padding,
|
||||
}}
|
||||
ref={buttonElement}
|
||||
onClick={handleOnClick}
|
||||
type={type}
|
||||
data-cy={rest['data-cy'] ?? id}
|
||||
data-tool={rest['data-tool']}
|
||||
>
|
||||
{React.cloneElement(children, {
|
||||
className: classnames(iconSizeClasses[size], 'fill-current'),
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
IconButton.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
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']),
|
||||
fullWidth: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
type: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
className: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default IconButton;
|
||||
2
platform/ui/src/components/IconButton/index.js
Normal file
2
platform/ui/src/components/IconButton/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import IconButton from './IconButton';
|
||||
export default IconButton;
|
||||
106
platform/ui/src/components/ImageScrollbar/ImageScrollbar.css
Normal file
106
platform/ui/src/components/ImageScrollbar/ImageScrollbar.css
Normal file
@@ -0,0 +1,106 @@
|
||||
.scroll {
|
||||
height: calc(100% - 30px);
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 30px;
|
||||
}
|
||||
.scroll .scroll-holder {
|
||||
height: calc(100%);
|
||||
margin-bottom: 5px;
|
||||
position: relative;
|
||||
width: 12px;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider {
|
||||
height: 12px;
|
||||
left: 12px;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: rotate(90deg);
|
||||
transform-origin: top left;
|
||||
-webkit-appearance: none;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider:focus {
|
||||
outline: none;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-moz-focus-outer {
|
||||
border: none;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-webkit-slider-runnable-track {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
height: 5px;
|
||||
z-index: 6;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-moz-range-track {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
height: 2px;
|
||||
z-index: 6;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-ms-track {
|
||||
animate: 0.2s;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-width: 15px 0;
|
||||
color: rgba(0, 0, 0, 0);
|
||||
cursor: pointer;
|
||||
height: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-ms-fill-lower {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-ms-fill-upper {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none !important;
|
||||
background-color: #163239;
|
||||
border: none;
|
||||
border-radius: 57px;
|
||||
cursor: -webkit-grab;
|
||||
height: 12px;
|
||||
margin-top: -4px;
|
||||
width: 39px;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-webkit-slider-thumb:active {
|
||||
background-color: #20a5d6;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-moz-range-thumb {
|
||||
background-color: #163239;
|
||||
border: none;
|
||||
border-radius: 57px;
|
||||
cursor: -moz-grab;
|
||||
height: 12px;
|
||||
width: 39px;
|
||||
z-index: 7;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-moz-range-thumb:active {
|
||||
background-color: #20a5d6;
|
||||
cursor: -moz-grabbing;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-ms-thumb {
|
||||
background-color: #163239;
|
||||
border: none;
|
||||
border-radius: 57px;
|
||||
cursor: ns-resize;
|
||||
height: 12px;
|
||||
width: 39px;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-ms-thumb:active {
|
||||
background-color: #20a5d6;
|
||||
}
|
||||
.scroll .scroll-holder .imageSlider::-ms-tooltip {
|
||||
display: none;
|
||||
}
|
||||
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
|
||||
.imageSlider {
|
||||
left: 50px;
|
||||
}
|
||||
}
|
||||
75
platform/ui/src/components/ImageScrollbar/ImageScrollbar.tsx
Normal file
75
platform/ui/src/components/ImageScrollbar/ImageScrollbar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ImageScrollbar.css';
|
||||
|
||||
class ImageScrollbar extends PureComponent {
|
||||
static propTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
max: PropTypes.number.isRequired,
|
||||
height: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onContextMenu: PropTypes.func,
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.props.max === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.style = {
|
||||
width: `${this.props.height}`,
|
||||
};
|
||||
|
||||
const { onContextMenu = e => e.preventDefault() } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="scroll"
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<div className="scroll-holder">
|
||||
<input
|
||||
// adding mousetrap let the mousetrap know about the scrollbar otherwise,
|
||||
// it will not capture the keyboard event
|
||||
className="imageSlider mousetrap"
|
||||
style={this.style}
|
||||
type="range"
|
||||
min="0"
|
||||
max={this.props.max}
|
||||
step="1"
|
||||
value={this.props.value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onChange = event => {
|
||||
const intValue = parseInt(event.target.value, 10);
|
||||
this.props.onChange(intValue);
|
||||
};
|
||||
|
||||
onKeyDown = event => {
|
||||
// We don't allow direct keyboard up/down input on the
|
||||
// image sliders since the natural direction is reversed (0 is at the top)
|
||||
|
||||
// Store the KeyCodes in an object for readability
|
||||
const keys = {
|
||||
DOWN: 40,
|
||||
UP: 38,
|
||||
};
|
||||
|
||||
// TODO: Enable scroll down / scroll up without depending on ohif-core
|
||||
if (event.which === keys.DOWN) {
|
||||
//OHIF.commands.run('scrollDown');
|
||||
event.preventDefault();
|
||||
} else if (event.which === keys.UP) {
|
||||
//OHIF.commands.run('scrollUp');
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default ImageScrollbar;
|
||||
2
platform/ui/src/components/ImageScrollbar/index.js
Normal file
2
platform/ui/src/components/ImageScrollbar/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import ImageScrollbar from './ImageScrollbar';
|
||||
export default ImageScrollbar;
|
||||
91
platform/ui/src/components/Input/Input.tsx
Normal file
91
platform/ui/src/components/Input/Input.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Label from '../Label';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const baseInputClasses =
|
||||
'shadow transition duration-300 appearance-none border border-inputfield-main focus:border-inputfield-focus focus:outline-none disabled:border-inputfield-disabled rounded w-full py-2 px-3 text-sm text-white placeholder-inputfield-placeholder leading-tight';
|
||||
|
||||
const transparentClasses = {
|
||||
true: 'bg-transparent',
|
||||
false: 'bg-black',
|
||||
};
|
||||
|
||||
const smallInputClasses = {
|
||||
true: 'input-small',
|
||||
false: '',
|
||||
};
|
||||
|
||||
const Input = ({
|
||||
id,
|
||||
label,
|
||||
containerClassName = '',
|
||||
labelClassName = '',
|
||||
className = '',
|
||||
transparent = false,
|
||||
smallInput = false,
|
||||
type = 'text',
|
||||
value,
|
||||
onChange,
|
||||
onFocus,
|
||||
autoFocus,
|
||||
onKeyPress,
|
||||
onKeyDown,
|
||||
readOnly,
|
||||
disabled,
|
||||
labelChildren,
|
||||
...otherProps
|
||||
}) => {
|
||||
return (
|
||||
<div className={classnames('flex flex-1 flex-col', containerClassName)}>
|
||||
<Label
|
||||
className={labelClassName}
|
||||
text={label}
|
||||
children={labelChildren}
|
||||
></Label>
|
||||
<input
|
||||
data-cy={`input-${id}`}
|
||||
className={classnames(
|
||||
label && 'mt-2',
|
||||
className,
|
||||
baseInputClasses,
|
||||
transparentClasses[transparent],
|
||||
smallInputClasses[smallInput],
|
||||
{ 'cursor-not-allowed': disabled }
|
||||
)}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
onKeyPress={onKeyPress}
|
||||
onKeyDown={onKeyDown}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Input.propTypes = {
|
||||
id: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
containerClassName: PropTypes.string,
|
||||
labelClassName: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
transparent: PropTypes.bool,
|
||||
smallInput: PropTypes.bool,
|
||||
type: PropTypes.string,
|
||||
value: PropTypes.any,
|
||||
onChange: PropTypes.func,
|
||||
onFocus: PropTypes.func,
|
||||
autoFocus: PropTypes.bool,
|
||||
readOnly: PropTypes.bool,
|
||||
onKeyPress: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
labelChildren: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,94 @@
|
||||
import Input from '../Input';
|
||||
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
import { createComponentTemplate } from '../../../storybook/functions/create-component-story';
|
||||
|
||||
export const argTypes = {
|
||||
component: Input,
|
||||
title: 'Components/Input',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/Input"
|
||||
component={Input}
|
||||
/>
|
||||
|
||||
export const InputTemplate = createComponentTemplate(Input);
|
||||
|
||||
<Heading
|
||||
title="Input"
|
||||
componentRelativePath="Input/Input.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Input is a component that renders the Inputs.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
label: 'Input',
|
||||
}}
|
||||
>
|
||||
{InputTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={Input} />
|
||||
|
||||
## Usage
|
||||
|
||||
### Transparent
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Transparent"
|
||||
args={{
|
||||
transparent: true,
|
||||
}}
|
||||
>
|
||||
{InputTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Small Input
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Small"
|
||||
args={{
|
||||
smallInput: true,
|
||||
}}
|
||||
>
|
||||
{InputTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
### Classnames
|
||||
|
||||
You can change the appearance of the container and label.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="ClassNames"
|
||||
args={{
|
||||
label: 'Input',
|
||||
containerClassName: 'bg-gray-500',
|
||||
labelClassName: 'text-yellow-500',
|
||||
}}
|
||||
>
|
||||
{InputTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="Input/__stories__/input.stories.mdx" />
|
||||
2
platform/ui/src/components/Input/index.js
Normal file
2
platform/ui/src/components/Input/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import Input from './Input';
|
||||
export default Input;
|
||||
59
platform/ui/src/components/InputDateRange/InputDateRange.tsx
Normal file
59
platform/ui/src/components/InputDateRange/InputDateRange.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { DatePickerWithRange } from '@ohif/ui-next';
|
||||
import InputLabelWrapper from '../InputLabelWrapper';
|
||||
|
||||
const InputDateRange = ({
|
||||
id,
|
||||
label,
|
||||
isSortable,
|
||||
sortDirection,
|
||||
onLabelClick = () => {},
|
||||
value = {},
|
||||
onChange,
|
||||
}) => {
|
||||
const { startDate, endDate } = value;
|
||||
|
||||
const onClickHandler = event => {
|
||||
event.preventDefault();
|
||||
onLabelClick(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputLabelWrapper
|
||||
label={label}
|
||||
isSortable={isSortable}
|
||||
sortDirection={sortDirection}
|
||||
onLabelClick={onClickHandler}
|
||||
className="xl:min-w-[284px]"
|
||||
>
|
||||
<div className="relative xl:max-w-[246px]">
|
||||
<DatePickerWithRange
|
||||
className="mt-2"
|
||||
id={id}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</InputLabelWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
InputDateRange.propTypes = {
|
||||
id: PropTypes.string,
|
||||
label: PropTypes.string.isRequired,
|
||||
isSortable: PropTypes.bool.isRequired,
|
||||
sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']).isRequired,
|
||||
onLabelClick: PropTypes.func.isRequired,
|
||||
value: PropTypes.shape({
|
||||
/** YYYYMMDD (19921022) */
|
||||
startDate: PropTypes.string,
|
||||
/** YYYYMMDD (19921022) */
|
||||
endDate: PropTypes.string,
|
||||
}),
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default InputDateRange;
|
||||
2
platform/ui/src/components/InputDateRange/index.js
Normal file
2
platform/ui/src/components/InputDateRange/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import InputDateRange from './InputDateRange';
|
||||
export default InputDateRange;
|
||||
@@ -0,0 +1,4 @@
|
||||
.input-range-thumb-design {
|
||||
@apply bg-primary-light border-primary-dark border-[2px] border-solid;
|
||||
border-radius: 50%;
|
||||
}
|
||||
238
platform/ui/src/components/InputDoubleRange/InputDoubleRange.tsx
Normal file
238
platform/ui/src/components/InputDoubleRange/InputDoubleRange.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { InputNumber } from '../../components'; // Import InputNumber component
|
||||
import './InputDoubleRange.css';
|
||||
|
||||
type InputDoubleRangeProps = {
|
||||
values: [number, number];
|
||||
onChange: (values: [number, number]) => void;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
containerClassName?: string;
|
||||
inputClassName?: string;
|
||||
labelClassName?: string;
|
||||
labelVariant?: string;
|
||||
showLabel?: boolean;
|
||||
labelPosition?: 'left' | 'right';
|
||||
trackColor?: string;
|
||||
allowNumberEdit?: boolean;
|
||||
showAdjustmentArrows?: boolean;
|
||||
allowOutOfRange?: boolean;
|
||||
};
|
||||
|
||||
const InputDoubleRange: React.FC<InputDoubleRangeProps> = ({
|
||||
values,
|
||||
onChange,
|
||||
minValue = 0,
|
||||
maxValue = 100,
|
||||
step = 1,
|
||||
unit = '',
|
||||
containerClassName = '',
|
||||
inputClassName = '',
|
||||
labelClassName = '',
|
||||
labelVariant = 'body1',
|
||||
showLabel = false,
|
||||
labelPosition = 'left',
|
||||
trackColor = 'primary',
|
||||
allowNumberEdit = false,
|
||||
allowOutOfRange = false,
|
||||
showAdjustmentArrows = false,
|
||||
}) => {
|
||||
// Set initial thumb positions as percentages
|
||||
const initialPercentageStart = Math.round(((values[0] - minValue) / (maxValue - minValue)) * 100);
|
||||
const initialPercentageEnd = Math.round(((values[1] - minValue) / (maxValue - minValue)) * 100);
|
||||
|
||||
const [percentageStart, setPercentageStart] = useState(initialPercentageStart);
|
||||
const [percentageEnd, setPercentageEnd] = useState(initialPercentageEnd);
|
||||
|
||||
const [rangeValue, setRangeValue] = useState(values);
|
||||
const selectedThumbRef = useRef(null);
|
||||
const sliderRef = useRef(null);
|
||||
|
||||
const updateRangeValues = (newValues, index = null) => {
|
||||
const updatedRangeValue = Array.isArray(newValues) ? [...newValues] : [...rangeValue];
|
||||
if (index !== null) {
|
||||
updatedRangeValue[index] = newValues;
|
||||
}
|
||||
|
||||
const calculatePercentage = value => {
|
||||
if (value < minValue) {
|
||||
return 0;
|
||||
}
|
||||
if (value > maxValue) {
|
||||
return 100;
|
||||
}
|
||||
return ((value - minValue) / (maxValue - minValue)) * 100;
|
||||
};
|
||||
|
||||
const newPercentageStart = calculatePercentage(updatedRangeValue[0]);
|
||||
const newPercentageEnd = calculatePercentage(updatedRangeValue[1]);
|
||||
|
||||
setRangeValue(updatedRangeValue);
|
||||
onChange(updatedRangeValue);
|
||||
|
||||
setPercentageStart(newPercentageStart);
|
||||
setPercentageEnd(newPercentageEnd);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateRangeValues(values);
|
||||
}, [values, minValue, maxValue]);
|
||||
|
||||
const LabelOrEditableNumber = (val, index) => {
|
||||
return allowNumberEdit ? (
|
||||
// the pl-[2px] class is used to align the thumb so that it doesn't
|
||||
// go over the label when the value is full, not sure what is wrong
|
||||
// with the implementation, we need to fix it properly
|
||||
<div className={index === 1 && 'pl-[2px]'}>
|
||||
<InputNumber
|
||||
minValue={minValue}
|
||||
maxValue={maxValue}
|
||||
value={val}
|
||||
onChange={newValue => {
|
||||
updateRangeValues(newValue, index);
|
||||
}}
|
||||
step={step}
|
||||
labelClassName={classNames(labelClassName ?? 'text-white')}
|
||||
showAdjustmentArrows={showAdjustmentArrows}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className={classNames(labelClassName ?? 'text-white')}>
|
||||
{val}
|
||||
{unit}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGlobalMouseUp = () => {
|
||||
// Remove global mouse event listeners
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
selectedThumbRef.current = null;
|
||||
};
|
||||
|
||||
const handleMouseDown = e => {
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentageClicked = (x / rect.width) * 100;
|
||||
|
||||
// Calculate the distances from the clicked point to both thumbs' positions
|
||||
const distanceToStartThumb = Math.abs(percentageClicked - percentageStart);
|
||||
const distanceToEndThumb = Math.abs(percentageClicked - percentageEnd);
|
||||
|
||||
// Check if the clicked point is within a threshold distance to either thumb
|
||||
if (distanceToStartThumb < 10) {
|
||||
selectedThumbRef.current = 0;
|
||||
} else if (distanceToEndThumb < 10) {
|
||||
selectedThumbRef.current = 1;
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
|
||||
const handleMouseMove = e => {
|
||||
const selectedThumbValue = selectedThumbRef.current;
|
||||
|
||||
if (selectedThumbValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const newValue =
|
||||
Math.round(((x / rect.width) * (maxValue - minValue) + minValue) / step) * step;
|
||||
|
||||
if (!allowOutOfRange) {
|
||||
const clampedValue = Math.min(Math.max(newValue, minValue), maxValue);
|
||||
|
||||
const updatedRangeValue = [...rangeValue];
|
||||
updatedRangeValue[selectedThumbValue] = clampedValue;
|
||||
setRangeValue(updatedRangeValue);
|
||||
|
||||
onChange(updatedRangeValue);
|
||||
|
||||
const percentage = Math.round(((clampedValue - minValue) / (maxValue - minValue)) * 100);
|
||||
if (selectedThumbValue === 0) {
|
||||
setPercentageStart(percentage);
|
||||
} else {
|
||||
setPercentageEnd(percentage);
|
||||
}
|
||||
} else {
|
||||
const updatedRangeValue = [...rangeValue];
|
||||
updatedRangeValue[selectedThumbValue] = newValue;
|
||||
setRangeValue(updatedRangeValue);
|
||||
|
||||
onChange(updatedRangeValue);
|
||||
|
||||
// Update the thumb position
|
||||
const percentage = Math.round(((newValue - minValue) / (maxValue - minValue)) * 100);
|
||||
if (percentage < 0) {
|
||||
if (selectedThumbValue === 0) {
|
||||
setPercentageStart(0);
|
||||
} else {
|
||||
setPercentageEnd(0);
|
||||
}
|
||||
} else if (percentage > 100) {
|
||||
if (selectedThumbValue === 0) {
|
||||
setPercentageStart(100);
|
||||
} else {
|
||||
setPercentageEnd(100);
|
||||
}
|
||||
} else {
|
||||
if (selectedThumbValue === 0) {
|
||||
setPercentageStart(percentage);
|
||||
} else {
|
||||
setPercentageEnd(percentage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the correct values in the rangeValue array
|
||||
};
|
||||
|
||||
// Calculate the range values percentages for gradient background
|
||||
const rangeValuePercentageStart = ((rangeValue[0] - minValue) / (maxValue - minValue)) * 100;
|
||||
const rangeValuePercentageEnd = ((rangeValue[1] - minValue) / (maxValue - minValue)) * 100;
|
||||
|
||||
return (
|
||||
<div className={`flex select-none items-center space-x-2 ${containerClassName ?? ''}`}>
|
||||
{showLabel && LabelOrEditableNumber(rangeValue[0], 0)}
|
||||
<div
|
||||
className="relative flex h-10 w-full items-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
ref={sliderRef}
|
||||
>
|
||||
<div
|
||||
className="h-[3px] w-full rounded-lg"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #3a3f99 0%, #3a3f99 ${rangeValuePercentageStart}%, #5acce6 ${rangeValuePercentageStart}%, #5acce6 ${rangeValuePercentageEnd}%, #3a3f99 ${rangeValuePercentageEnd}%, #3a3f99 100%)`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="input-range-thumb-design absolute h-3 w-3 cursor-pointer"
|
||||
style={{
|
||||
left: `calc(${percentageStart}% - 3px)`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="input-range-thumb-design absolute h-3 w-3 cursor-pointer rounded-full"
|
||||
style={{ left: `calc(${percentageEnd}% - 3px)` }}
|
||||
></div>
|
||||
</div>
|
||||
{showLabel && LabelOrEditableNumber(rangeValue[1], 1)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputDoubleRange;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { InputDoubleRange } from '../../../components';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: InputDoubleRange,
|
||||
title: 'Components/InputDoubleRange',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/InputDoubleRange"
|
||||
component={InputDoubleRange}
|
||||
/>
|
||||
|
||||
export const RangeInputTemplate = args => (
|
||||
<div className="w-12">
|
||||
<InputDoubleRange {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="InputDoubleRange"
|
||||
componentRelativePath="InputDoubleRange/InputDoubleRange.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
InputDoubleRange is a component that allows you to use as a boolean value
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
values: [100, 200],
|
||||
minValue: 0,
|
||||
maxValue: 300,
|
||||
step: 10,
|
||||
unit: '%',
|
||||
onChange: () => console.log('input range change'),
|
||||
labelClassName: 'text-black',
|
||||
}}
|
||||
>
|
||||
{RangeInputTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={InputDoubleRange} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="InputDoubleRange/__stories__/InputDoubleRange.stories.mdx" />
|
||||
3
platform/ui/src/components/InputDoubleRange/index.js
Normal file
3
platform/ui/src/components/InputDoubleRange/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import InputDoubleRange from './InputDoubleRange';
|
||||
|
||||
export default InputDoubleRange;
|
||||
@@ -0,0 +1,84 @@
|
||||
import classNames from 'classnames';
|
||||
import debounce from 'lodash.debounce';
|
||||
import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import Icon from '../Icon';
|
||||
|
||||
type InputFilterTextProps = {
|
||||
className?: string;
|
||||
value?: string;
|
||||
placeholder: string;
|
||||
onDebounceChange?: (val: string) => void;
|
||||
onChange?: (val: string) => void;
|
||||
debounceTime?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component to use as the input for text to filter by/on. A debounced callback is automatically provided
|
||||
* so that the filtering in-turn will be debounced. There is also a straight onChange callback when the filter value is
|
||||
* required immediately and NOT debounced. The debounce time is also configurable.
|
||||
*/
|
||||
const InputFilterText = ({
|
||||
className,
|
||||
value = '',
|
||||
placeholder,
|
||||
onDebounceChange,
|
||||
onChange,
|
||||
debounceTime = 200,
|
||||
}: InputFilterTextProps): ReactElement => {
|
||||
const [filterValue, setFilterValue] = useState<string>(value);
|
||||
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const debouncedOnChange = useMemo(() => {
|
||||
return debounce(onDebounceChange || (() => {}), debounceTime);
|
||||
}, []);
|
||||
|
||||
// This allows for the filter value to be updated via the props.
|
||||
useEffect(() => setFilterValue(value), [value]);
|
||||
|
||||
useEffect(() => {
|
||||
return debouncedOnChange?.cancel();
|
||||
}, []);
|
||||
|
||||
const handleFilterTextChanged = useCallback(value => {
|
||||
setFilterValue(value);
|
||||
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
|
||||
if (onDebounceChange) {
|
||||
debouncedOnChange(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<label className={classNames('relative', className)}>
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-2">
|
||||
<Icon name="icon-search"></Icon>
|
||||
</span>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="border-inputfield-main focus:border-inputfield-focus disabled:border-inputfield-disabled placeholder:text-inputfield-placeholder block w-full w-full appearance-none rounded-md border bg-black py-2 px-9 text-base leading-tight shadow transition duration-300 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
onChange={event => handleFilterTextChanged(event.target.value)}
|
||||
autoComplete="off"
|
||||
value={filterValue}
|
||||
></input>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<Icon
|
||||
name="icon-clear-field"
|
||||
className={classNames('cursor-pointer', filterValue ? '' : 'hidden')}
|
||||
onClick={() => {
|
||||
searchInputRef.current.value = '';
|
||||
handleFilterTextChanged('');
|
||||
}}
|
||||
></Icon>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputFilterText;
|
||||
@@ -0,0 +1,59 @@
|
||||
import InputFilterText from '../InputFilterText';
|
||||
import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs';
|
||||
|
||||
export const argTypes = {
|
||||
component: InputFilterText,
|
||||
title: 'Components/InputFilterText',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/InputFilterText"
|
||||
component={InputFilterText}
|
||||
/>
|
||||
|
||||
export const InputFilterTextTemplate = args => (
|
||||
<div className="w-80">
|
||||
<InputFilterText {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="InputFilterText"
|
||||
componentRelativePath="InputFilterText/InputFilterText.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
InputFilterText is a component that is styled such that it can be used as a text input to filter a
|
||||
list of textual items. It allows you to enter any text. There are two (optional) callbacks that can
|
||||
be invoked as the characters of the text are entered: one callback is invoked as each character is
|
||||
typed and another is debounced so that any filtering can occur once the user has entered a
|
||||
significant amount of info and pausing by a configurable amount of time in milliseconds. The
|
||||
component also provides a button on the far right of the component that when clicked will clear the
|
||||
input text. The button only appears when there is text in the component.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
className: 'w-full text-white',
|
||||
placeholder: 'Search for...',
|
||||
onChange: () => console.log('input text changed'),
|
||||
onDebounceChange: () => console.log('debounce text changed'),
|
||||
}}
|
||||
>
|
||||
{InputFilterTextTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={InputFilterText} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="InputFilterText/__stories__/InputFilterText.stories.mdx" />
|
||||
2
platform/ui/src/components/InputFilterText/index.js
Normal file
2
platform/ui/src/components/InputFilterText/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import InputFilterText from './InputFilterText';
|
||||
export default InputFilterText;
|
||||
163
platform/ui/src/components/InputGroup/InputGroup.tsx
Normal file
163
platform/ui/src/components/InputGroup/InputGroup.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import getGridWidthClass from '../../utils/getGridWidthClass';
|
||||
|
||||
import InputText from '../InputText';
|
||||
import InputDateRange from '../InputDateRange';
|
||||
import InputMultiSelect from '../InputMultiSelect';
|
||||
import InputLabelWrapper from '../InputLabelWrapper';
|
||||
|
||||
const InputGroup = ({
|
||||
inputMeta,
|
||||
values,
|
||||
onValuesChange,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
isSortingEnabled,
|
||||
}) => {
|
||||
const { sortBy, sortDirection } = sorting;
|
||||
|
||||
const handleFilterLabelClick = name => {
|
||||
if (isSortingEnabled) {
|
||||
let _sortDirection = 'descending';
|
||||
if (sortBy === name) {
|
||||
if (sortDirection === 'ascending') {
|
||||
_sortDirection = 'descending';
|
||||
} else if (sortDirection === 'descending') {
|
||||
_sortDirection = 'ascending';
|
||||
}
|
||||
}
|
||||
|
||||
onSortingChange({
|
||||
sortBy: _sortDirection !== 'none' ? name : '',
|
||||
sortDirection: _sortDirection,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderFieldInputComponent = ({ name, displayName, inputProps, isSortable, inputType }) => {
|
||||
const _isSortable = isSortable && isSortingEnabled;
|
||||
const _sortDirection = sortBy !== name ? 'none' : sortDirection;
|
||||
|
||||
const onLabelClick = () => {
|
||||
handleFilterLabelClick(name);
|
||||
};
|
||||
|
||||
const handleFieldChange = newValue => {
|
||||
onValuesChange({
|
||||
...values,
|
||||
[name]: newValue,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateRangeFieldChange = ({ startDate, endDate }) => {
|
||||
onValuesChange({
|
||||
...values,
|
||||
[name]: {
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
switch (inputType) {
|
||||
case 'Text':
|
||||
return (
|
||||
<InputText
|
||||
id={name}
|
||||
key={name}
|
||||
label={displayName}
|
||||
isSortable={_isSortable}
|
||||
sortDirection={_sortDirection}
|
||||
onLabelClick={onLabelClick}
|
||||
value={values[name]}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
case 'MultiSelect':
|
||||
return (
|
||||
<InputMultiSelect
|
||||
id={name}
|
||||
key={name}
|
||||
label={displayName}
|
||||
isSortable={_isSortable}
|
||||
sortDirection={_sortDirection}
|
||||
onLabelClick={onLabelClick}
|
||||
value={values[name]}
|
||||
onChange={handleFieldChange}
|
||||
options={inputProps.options}
|
||||
/>
|
||||
);
|
||||
case 'DateRange':
|
||||
return (
|
||||
<InputDateRange
|
||||
id={name}
|
||||
key={name}
|
||||
label={displayName}
|
||||
isSortable={_isSortable}
|
||||
sortDirection={_sortDirection}
|
||||
onLabelClick={onLabelClick}
|
||||
value={values[name]}
|
||||
onChange={handleDateRangeFieldChange}
|
||||
/>
|
||||
);
|
||||
case 'None':
|
||||
return (
|
||||
<InputLabelWrapper
|
||||
key={name}
|
||||
label={displayName}
|
||||
isSortable={_isSortable}
|
||||
sortDirection={_sortDirection}
|
||||
onLabelClick={onLabelClick}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="container relative m-auto flex flex-col">
|
||||
<div className="flex w-full flex-row">
|
||||
{inputMeta.map(inputMeta => {
|
||||
return (
|
||||
<div
|
||||
key={inputMeta.name}
|
||||
className={classnames('pl-4 first:pl-12', getGridWidthClass(inputMeta.gridCol))}
|
||||
>
|
||||
{renderFieldInputComponent(inputMeta)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InputGroup.propTypes = {
|
||||
inputMeta: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
inputType: PropTypes.oneOf(['Text', 'MultiSelect', 'DateRange', 'None']).isRequired,
|
||||
isSortable: PropTypes.bool.isRequired,
|
||||
gridCol: PropTypes.oneOf([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]).isRequired,
|
||||
option: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
value: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
})
|
||||
),
|
||||
})
|
||||
).isRequired,
|
||||
values: PropTypes.object.isRequired,
|
||||
onValuesChange: PropTypes.func.isRequired,
|
||||
sorting: PropTypes.shape({
|
||||
sortBy: PropTypes.string,
|
||||
sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']),
|
||||
}).isRequired,
|
||||
onSortingChange: PropTypes.func.isRequired,
|
||||
isSortingEnabled: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default InputGroup;
|
||||
2
platform/ui/src/components/InputGroup/index.js
Normal file
2
platform/ui/src/components/InputGroup/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import InputGroup from './InputGroup';
|
||||
export default InputGroup;
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import Icon from '../Icon';
|
||||
|
||||
const baseLabelClassName = 'flex flex-col flex-1 text-white text-lg pl-1 select-none';
|
||||
const spanClassName = 'flex flex-row items-center cursor-pointer focus:outline-none';
|
||||
const sortIconMap = {
|
||||
descending: 'sorting-active-up',
|
||||
ascending: 'sorting-active-down',
|
||||
none: 'sorting',
|
||||
};
|
||||
|
||||
const InputLabelWrapper = ({
|
||||
label,
|
||||
isSortable,
|
||||
sortDirection,
|
||||
onLabelClick,
|
||||
className = '',
|
||||
children,
|
||||
}) => {
|
||||
const onClickHandler = e => {
|
||||
if (!isSortable) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLabelClick(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={classnames(baseLabelClassName, className)}>
|
||||
<span
|
||||
role="button"
|
||||
className={spanClassName}
|
||||
onClick={onClickHandler}
|
||||
onKeyDown={onClickHandler}
|
||||
tabIndex="0"
|
||||
>
|
||||
{label}
|
||||
{isSortable && (
|
||||
<Icon
|
||||
name={sortIconMap[sortDirection]}
|
||||
className={classnames(
|
||||
'mx-2 w-2',
|
||||
sortDirection !== 'none' ? 'text-primary-light' : 'text-primary-main'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
InputLabelWrapper.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
isSortable: PropTypes.bool.isRequired,
|
||||
sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']).isRequired,
|
||||
onLabelClick: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default InputLabelWrapper;
|
||||
2
platform/ui/src/components/InputLabelWrapper/index.js
Normal file
2
platform/ui/src/components/InputLabelWrapper/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import InputLabelWrapper from './InputLabelWrapper';
|
||||
export default InputLabelWrapper;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Select from '../Select';
|
||||
import InputLabelWrapper from '../InputLabelWrapper';
|
||||
|
||||
const InputMultiSelect = ({
|
||||
id,
|
||||
label,
|
||||
isSortable,
|
||||
sortDirection,
|
||||
onLabelClick,
|
||||
value = [],
|
||||
placeholder = '',
|
||||
options = [],
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<InputLabelWrapper
|
||||
label={label}
|
||||
isSortable={isSortable}
|
||||
sortDirection={sortDirection}
|
||||
onLabelClick={onLabelClick}
|
||||
>
|
||||
<Select
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
className="mt-2"
|
||||
options={options}
|
||||
value={value}
|
||||
isMulti={true}
|
||||
isClearable={false}
|
||||
isSearchable={true}
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
onChange={(selectedOptions, action) => {
|
||||
switch (action) {
|
||||
case 'select-option':
|
||||
case 'remove-value':
|
||||
case 'deselect-option':
|
||||
case 'clear':
|
||||
onChange(selectedOptions);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</InputLabelWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
InputMultiSelect.propTypes = {
|
||||
id: PropTypes.string,
|
||||
label: PropTypes.string.isRequired,
|
||||
isSortable: PropTypes.bool.isRequired,
|
||||
sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']).isRequired,
|
||||
onLabelClick: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
/** Array of options to list as options */
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
value: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
})
|
||||
),
|
||||
/** Array of string values that exist in our list of options */
|
||||
value: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
export default InputMultiSelect;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user