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

View File

@@ -0,0 +1,59 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import { cn } from '../../lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn(className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-2 px-2 text-base font-medium transition-transform duration-200',
className,
'[&[data-state=open]>svg]:rotate-270',
'[&[data-state=closed]>svg]:rotate-90'
)}
{...props}
>
{children}
<ChevronDownIcon className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-base"
{...props}
>
<div className={cn(className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,3 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './Accordion';
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -0,0 +1,38 @@
'use client';
import * as React from 'react';
import { useState, useEffect } from 'react';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../Select';
const BackgroundColorSelect: React.FC = () => {
const [selectedColor, setSelectedColor] = useState('#050615');
useEffect(() => {
const rows = document.querySelectorAll('.row') as NodeListOf<HTMLElement>;
rows.forEach(row => {
row.style.backgroundColor = selectedColor;
});
}, [selectedColor]);
const handleColorChange = (value: string) => {
setSelectedColor(value);
};
return (
<div>
<Select onValueChange={handleColorChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select Color" />
</SelectTrigger>
<SelectContent>
<SelectItem value="black">Viewport (Black)</SelectItem>
<SelectItem value="#050615">Base</SelectItem>
<SelectItem value="#090C29">Medium</SelectItem>
<SelectItem value="#041C4A">Header</SelectItem>
</SelectContent>
</Select>
</div>
);
};
export default BackgroundColorSelect;

View File

@@ -0,0 +1 @@
export { default as BackgroundColorSelect } from './BackgroundColorSelect';

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded text-base font-normal leading-tight transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary/60 text-primary-foreground hover:bg-primary/100',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-primary/25 bg-background hover:bg-primary/25 text-primary hover:text-primary',
secondary: 'bg-primary/40 text-secondary-foreground hover:bg-primary/60',
ghost: 'font-normal text-primary hover:bg-primary/25',
link: 'font-normal text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-7 px-2 py-2',
sm: 'h-6 rounded px-2',
lg: 'h-9 rounded px-2',
icon: 'h-6 w-6',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1 @@
export { Button, buttonVariants } from './Button';

View File

@@ -0,0 +1,61 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '../../lib/utils';
import { buttonVariants } from '../Button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
captionLayout="dropdown"
fromYear={1945}
toYear={2024}
labels={{
labelMonthDropdown: () => undefined,
labelYearDropdown: () => undefined,
}}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-between items-center px-2',
caption_dropdowns: 'flex space-x-2 text-black',
caption_label: 'hidden',
nav: 'space-x-1 flex items-center',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-base p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary/60 text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground focus:bg-primary/80 focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View File

@@ -0,0 +1,3 @@
import { Calendar } from './Calendar';
export { Calendar};

View File

@@ -0,0 +1,75 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'bg-card text-card-foreground border-input rounded-lg border shadow',
className
)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-muted-foreground text-base', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('p-6 pt-0', className)}
{...props}
/>
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,2 @@
import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
import { cn } from '../../lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'border-primary hover:bg-primary/20 focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer h-4 w-4 shrink-0 rounded-sm border shadow focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('text-background flex items-center justify-center')}>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,3 @@
import { Checkbox } from './Checkbox';
export { Checkbox };

View File

@@ -0,0 +1,66 @@
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Button } from '../Button/Button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '../Command/Command';
import { Popover, PopoverContent, PopoverTrigger } from '../Popover/Popover';
export function Combobox({ data = [], placeholder = 'Select item...' }) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState('');
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{value ? data.find(item => item.value === value)?.label : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
<CommandList>
<CommandGroup>
{data.map(item => (
<CommandItem
key={item.value}
value={item.value}
onSelect={currentValue => {
setValue(currentValue === value ? '' : currentValue);
setOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === item.value ? 'opacity-100' : 'opacity-0'
)}
/>
{item.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,3 @@
import { Combobox } from './Combobox';
export { Combobox};

View File

@@ -0,0 +1,150 @@
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
import { Command as CommandPrimitive } from 'cmdk';
import { cn } from '../../lib/utils';
import { Dialog, DialogContent } from '../Dialog/Dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div
className="flex items-center border-b px-3"
cmdk-input-wrapper=""
>
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-base"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-medium',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-base outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('text-muted-foreground ml-auto text-sm tracking-widest', className)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,23 @@
import {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
} from './Command';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,327 @@
import React, { useState } from 'react';
import { Button } from '../../components/Button/Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '../../components/DropdownMenu';
import { Icons } from '../../components/Icons/Icons';
import { Tooltip, TooltipTrigger, TooltipContent } from '../../components/Tooltip/Tooltip';
/**
* DataRow is a complex UI component that displays a selectable, interactive row with hierarchical data.
* It's designed to show a numbered item with a title, optional color indicator, and expandable details.
* The row supports various interactive features like visibility toggling, locking, and contextual actions.
*
* @component
* @example
* ```tsx
* <DataRow
* number={1}
* title="My Item"
* details={{
* primary: ["Main detail", " Sub detail"],
* secondary: []
* }}
* isVisible={true}
* isLocked={false}
* onToggleVisibility={() => {}}
* onToggleLocked={() => {}}
* onRename={() => {}}
* onDelete={() => {}}
* onColor={() => {}}
* />
* ```
*/
/**
* Props for the DataRow component
* @interface DataRowProps
* @property {number} number - The display number/index of the row
* @property {string} title - The main text label for the row
* @property {boolean} disableEditing - When true, prevents rename and delete operations
* @property {string} [colorHex] - Optional hex color code to display a color indicator
* @property {Object} [details] - Optional hierarchical details to display below the row
* @property {string[]} details.primary - Primary details shown immediately below the row
* @property {string[]} details.secondary - Secondary details (currently unused)
* @property {boolean} [isSelected] - Whether the row is currently selected
* @property {() => void} [onSelect] - Callback when the row is clicked/selected
* @property {boolean} isVisible - Controls the row's visibility state
* @property {() => void} onToggleVisibility - Callback to toggle visibility
* @property {boolean} isLocked - Controls the row's locked state
* @property {() => void} onToggleLocked - Callback to toggle locked state
* @property {() => void} onRename - Callback when rename is requested
* @property {() => void} onDelete - Callback when delete is requested
* @property {() => void} onColor - Callback when color change is requested
*/
interface DataRowProps {
number: number;
disableEditing: boolean;
description: string;
details?: { primary: string[]; secondary: string[] };
//
isSelected?: boolean;
onSelect?: () => void;
//
isVisible: boolean;
onToggleVisibility: () => void;
//
isLocked: boolean;
onToggleLocked: () => void;
//
title: string;
onRename: () => void;
//
onDelete: () => void;
//
colorHex?: string;
onColor: () => void;
}
const DataRow: React.FC<DataRowProps> = ({
number,
title,
colorHex,
details,
onSelect,
isLocked,
onToggleVisibility,
onToggleLocked,
onRename,
onDelete,
onColor,
isSelected = false,
isVisible = true,
disableEditing = false,
}) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const isTitleLong = title?.length > 25;
const handleAction = (action: string, e: React.MouseEvent) => {
e.stopPropagation();
switch (action) {
case 'Rename':
onRename();
break;
case 'Lock':
onToggleLocked();
break;
case 'Delete':
onDelete();
break;
case 'Color':
onColor();
break;
}
};
const decodeHTML = (html: string) => {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
};
const renderDetailText = (text: string, indent: number = 0) => {
const indentation = ' '.repeat(indent);
if (text === '') {
return (
<div
key={`empty-${indent}`}
className="h-2"
></div>
);
}
const cleanText = decodeHTML(text);
return (
<div
key={cleanText}
className="whitespace-pre-wrap"
>
{indentation}
{cleanText.includes(':') ? (
<>
<span className="font-medium">{cleanText.split(':')[0]}:</span>
{cleanText.split(':')[1]}
</>
) : (
<span className="font-medium">{cleanText}</span>
)}
</div>
);
};
const renderDetails = (details: string[]) => {
const visibleLines = details.slice(0, 4);
const hiddenLines = details.slice(4);
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">
<div className="flex flex-col space-y-1">
{visibleLines.map((line, lineIndex) =>
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
)}
</div>
{hiddenLines.length > 0 && (
<div className="text-muted-foreground mt-1 flex items-center text-sm">
<span>...</span>
<Icons.Info className="mr-1 h-5 w-5" />
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-md"
>
<div className="text-secondary-foreground flex flex-col space-y-1 text-sm leading-normal">
{details.map((line, lineIndex) =>
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
)}
</div>
</TooltipContent>
</Tooltip>
);
};
return (
<div className={`flex flex-col ${isVisible ? '' : 'opacity-60'}`}>
<div
className={`flex items-center ${
isSelected ? 'bg-popover' : 'bg-muted'
} group relative cursor-pointer`}
onClick={onSelect}
data-cy="data-row"
>
{/* Hover Overlay */}
<div className="bg-primary/20 pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100"></div>
{/* Number Box */}
<div
className={`flex h-7 max-h-7 w-7 flex-shrink-0 items-center justify-center rounded-l border-r border-black text-base ${
isSelected ? 'bg-highlight text-black' : 'bg-muted text-muted-foreground'
} overflow-hidden`}
>
{number}
</div>
{/* Color Circle (Optional) */}
{colorHex && (
<div className="flex h-7 w-5 items-center justify-center">
<span
className="ml-2 h-2 w-2 rounded-full"
style={{ backgroundColor: colorHex }}
></span>
</div>
)}
{/* Label with Conditional Tooltip */}
<div className="ml-2 flex-1 overflow-hidden">
{isTitleLong ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`cursor-default text-base ${
isSelected ? 'text-highlight' : 'text-muted-foreground'
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
>
{title}
</TooltipContent>
</Tooltip>
) : (
<span
className={`text-base ${
isSelected ? 'text-highlight' : 'text-muted-foreground'
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
>
{title}
</span>
)}
</div>
{/* Actions and Visibility Toggle */}
<div className="relative ml-2 flex items-center space-x-1">
{/* Visibility Toggle Icon */}
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || !isVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
aria-label={isVisible ? 'Hide' : 'Show'}
onClick={e => {
e.stopPropagation();
onToggleVisibility();
}}
>
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
</Button>
{/* Lock Icon (if needed) */}
{isLocked && <Icons.Lock className="text-muted-foreground h-6 w-6" />}
{/* Actions Dropdown Menu */}
<DropdownMenu onOpenChange={open => setIsDropdownOpen(open)}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || isDropdownOpen ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
aria-label="Actions"
onClick={e => e.stopPropagation()} // Prevent row selection on button click
>
<Icons.More className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!disableEditing && (
<>
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
<Icons.Rename className="text-foreground" />
<span className="pl-2">Rename</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={e => handleAction('Delete', e)}>
<Icons.Delete className="text-foreground" />
<span className="pl-2">Delete</span>
</DropdownMenuItem>
{onColor && (
<DropdownMenuItem onClick={e => handleAction('Color', e)}>
<Icons.ColorChange className="text-foreground" />
<span className="pl-2">Change Color</span>
</DropdownMenuItem>
)}
</>
)}
<DropdownMenuItem onClick={e => handleAction('Lock', e)}>
<Icons.Lock className="text-foreground" />
<span className="pl-2">{isLocked ? 'Unlock' : 'Lock'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{details && details.primary?.length > 0 && (
<div className="ml-7 px-2 py-2">
<div className="text-secondary-foreground text-base leading-normal">
{renderDetails(details.primary)}
</div>
</div>
)}
</div>
);
};
export default DataRow;

View File

@@ -0,0 +1,3 @@
import DataRow from './DataRow';
export { DataRow };

View File

@@ -0,0 +1,31 @@
/**
* Represents a single data item in a list or table structure
*
* @interface DataItem
*/
export type DataItem = {
/** Unique identifier for the data item */
id: number;
/** Primary text or name of the data item */
title: string;
/** Detailed text description of the data item */
description: string;
/** Additional optional field for extra information */
optionalField?: string;
/** Hex color code (e.g., '#FF0000') for visual representation */
colorHex?: string;
/** Additional details or metadata about the item */
details?: string;
};
/**
* Represents a group of related data items with a common type
*
* @interface ListGroup
*/
export type ListGroup = {
/** The type or category of the group */
type: string;
/** Array of DataItem objects belonging to this group */
items: DataItem[];
};

View File

@@ -0,0 +1,153 @@
import * as React from 'react';
import { format, parse, isValid } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Calendar } from '../Calendar';
import * as Popover from '../Popover';
export type DatePickerWithRangeProps = {
id: string;
/** YYYYMMDD (19921022) */
startDate: string;
/** YYYYMMDD (19921022) */
endDate: string;
/** Callback that received { startDate: string(YYYYMMDD), endDate: string(YYYYMMDD)} */
onChange: (value: { startDate: string; endDate: string }) => void;
};
export function DatePickerWithRange({
className,
id,
startDate,
endDate,
onChange,
...props
}: React.HTMLAttributes<HTMLDivElement> & DatePickerWithRangeProps) {
const [start, setStart] = React.useState<string>(
startDate ? format(parse(startDate, 'yyyyMMdd', new Date()), 'yyyy-MM-dd') : ''
);
const [end, setEnd] = React.useState<string>(
endDate ? format(parse(endDate, 'yyyyMMdd', new Date()), 'yyyy-MM-dd') : ''
);
const [openEnd, setOpenEnd] = React.useState(false);
const handleStartSelect = (selectedDate: Date | undefined) => {
if (selectedDate) {
const formattedDate = format(selectedDate, 'yyyy-MM-dd');
setStart(formattedDate);
setOpenEnd(true);
onChange({
startDate: format(selectedDate, 'yyyyMMdd'),
endDate: end.replace(/-/g, ''),
});
}
};
const handleEndSelect = (selectedDate: Date | undefined) => {
if (selectedDate) {
const formattedDate = format(selectedDate, 'yyyy-MM-dd');
setEnd(formattedDate);
setOpenEnd(false);
onChange({
startDate: start.replace(/-/g, ''),
endDate: format(selectedDate, 'yyyyMMdd'),
});
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>, type: 'start' | 'end') => {
const value = e.target.value;
const date = parse(value, 'yyyy-MM-dd', new Date());
if (type === 'start') {
setStart(value);
if (isValid(date)) {
handleStartSelect(date);
}
} else {
setEnd(value);
if (isValid(date)) {
handleEndSelect(date);
}
}
};
React.useEffect(() => {
setStart(startDate ? format(parse(startDate, 'yyyyMMdd', new Date()), 'yyyy-MM-dd') : '');
setEnd(endDate ? format(parse(endDate, 'yyyyMMdd', new Date()), 'yyyy-MM-dd') : '');
}, [startDate, endDate]);
return (
<div className={cn('flex gap-2', className)}>
<Popover.Popover>
<Popover.PopoverTrigger asChild>
<div className="relative w-full">
<CalendarIcon className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform text-white" />
<input
id={`${id}-start`}
type="text"
placeholder="Start date"
autoComplete="off"
value={start}
onChange={e => handleInputChange(e, 'start')}
className={cn(
'border-inputfield-main focus:border-inputfield-focus h-[32px] w-full justify-start rounded border bg-black py-[6.5px] pl-[6.5px] pr-[6.5px] text-left text-base font-normal hover:bg-black hover:text-white',
!start && 'text-muted-foreground'
)}
data-cy="input-date-range-start"
/>
</div>
</Popover.PopoverTrigger>
<Popover.PopoverContent
className="w-auto p-0"
align="start"
>
<Calendar
initialFocus
mode="single"
defaultMonth={start ? parse(start, 'yyyy-MM-dd', new Date()) : new Date()}
selected={start ? parse(start, 'yyyy-MM-dd', new Date()) : undefined}
onSelect={handleStartSelect}
numberOfMonths={1}
/>
</Popover.PopoverContent>
</Popover.Popover>
<Popover.Popover
open={openEnd}
onOpenChange={setOpenEnd}
>
<Popover.PopoverTrigger asChild>
<div className="relative w-full">
<CalendarIcon className="absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform text-white" />
<input
id={`${id}-end`}
type="text"
placeholder="End date"
autoComplete="off"
value={end}
onChange={e => handleInputChange(e, 'end')}
className={cn(
'border-inputfield-main focus:border-inputfield-focus h-full w-full justify-start rounded border bg-black py-[6.5px] pl-[6.5px] pr-[6.5px] text-left text-base font-normal hover:bg-black hover:text-white',
!end && 'text-muted-foreground'
)}
data-cy="input-date-range-end"
/>
</div>
</Popover.PopoverTrigger>
<Popover.PopoverContent
className="w-auto p-0"
align="start"
>
<Calendar
initialFocus
mode="single"
defaultMonth={start ? parse(start, 'yyyy-MM-dd', new Date()) : new Date()}
selected={end ? parse(end, 'yyyy-MM-dd', new Date()) : undefined}
onSelect={handleEndSelect}
numberOfMonths={1}
/>
</Popover.PopoverContent>
</Popover.Popover>
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { DatePickerWithRange } from './DateRange';
export { DatePickerWithRange };

View File

@@ -0,0 +1,105 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cn } from '../../lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-base', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,25 @@
import {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from './Dialog';
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Icons } from '../Icons';
import { useTranslation } from 'react-i18next';
import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip';
/**
* Displays a tooltip with a list of messages of a displaySet
* @param param0
* @returns
*/
const DisplaySetMessageListTooltip = ({ messages, id }): React.ReactNode => {
const { t } = useTranslation('Messages');
if (messages?.size()) {
return (
<>
<Tooltip>
<TooltipTrigger id={id}>
<Icons.StatusWarning
className="h-[20px] w-[20px]"
aria-hidden="true"
role="presentation"
/>
</TooltipTrigger>
<TooltipContent side="right">
<div className="max-w-68 text-left text-lg text-white">
<div
className="break-normal text-lg font-semibold text-blue-300"
style={{
marginLeft: '4px',
marginTop: '4px',
}}
>
{t('Display Set Messages')}
</div>
<ol
style={{
marginLeft: '4px',
marginRight: '4px',
}}
>
{messages.messages.map((message, index) => (
<li
style={{
marginTop: '6px',
marginBottom: '6px',
}}
key={index}
>
{index + 1}. {t(message.id)}
</li>
))}
</ol>
</div>
</TooltipContent>
</Tooltip>
</>
);
}
return <></>;
};
DisplaySetMessageListTooltip.propTypes = {
messages: PropTypes.object,
id: PropTypes.string,
};
export { DisplaySetMessageListTooltip };

View File

@@ -0,0 +1,3 @@
import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip';
export { DisplaySetMessageListTooltip };

View File

@@ -0,0 +1,120 @@
import React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '../../lib/utils';
import { Input } from '../Input';
interface DoubleSliderProps {
className?: string;
min: number;
max: number;
step?: number;
defaultValue?: [number, number];
onValueChange?: (value: [number, number]) => void;
}
const DoubleSlider = React.forwardRef<HTMLDivElement, DoubleSliderProps>(
({ className, min, max, step = 1, defaultValue = [min, max], onValueChange }, ref) => {
const [value, setValue] = React.useState<[number, number]>(defaultValue);
const prevDefaultValueRef = React.useRef<[number, number] | null>(null);
const isInteger = step % 1 === 0;
React.useEffect(() => {
// Only update if defaultValue has actually changed
if (
!prevDefaultValueRef.current ||
prevDefaultValueRef.current[0] !== defaultValue[0] ||
prevDefaultValueRef.current[1] !== defaultValue[1]
) {
setValue(defaultValue);
prevDefaultValueRef.current = defaultValue;
}
}, [defaultValue]);
const roundToStep = (num: number): number => {
const inverse = 1 / step;
return Math.round(num * inverse) / inverse;
};
const handleSliderChange = React.useCallback(
(newValue: number[]) => {
const clampedValue: [number, number] = [
roundToStep(Math.max(min, Math.min(newValue[0], max))),
roundToStep(Math.min(max, Math.max(newValue[1], min))),
];
setValue(clampedValue);
onValueChange?.(clampedValue);
},
[min, max, onValueChange, step]
);
const handleInputChange = React.useCallback(
(index: 0 | 1, inputValue: string) => {
const newValue = parseFloat(inputValue);
if (!isNaN(newValue)) {
const clampedValue: [number, number] = [...value];
clampedValue[index] = roundToStep(Math.min(Math.max(newValue, min), max));
if (index === 0 && clampedValue[0] > clampedValue[1]) {
clampedValue[1] = clampedValue[0];
} else if (index === 1 && clampedValue[1] < clampedValue[0]) {
clampedValue[0] = clampedValue[1];
}
setValue(clampedValue);
onValueChange?.(clampedValue);
}
},
[value, min, max, onValueChange, step]
);
const formatValue = (val: number) => {
return isInteger ? Math.round(val) : val;
};
return (
<div
ref={ref}
className={cn('flex w-full items-center space-x-2', className)}
>
<Input
type="number"
value={formatValue(value[0])}
onChange={e => handleInputChange(0, e.target.value)}
onBlur={() => handleInputChange(0, value[0].toString())}
className="w-14"
min={min}
max={max}
step={step}
/>
<SliderPrimitive.Root
className="relative flex h-4 w-full touch-none select-none items-center"
min={min}
max={max}
step={step}
value={value}
onValueChange={handleSliderChange}
>
<SliderPrimitive.Track className="bg-primary/30 relative h-1 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="border-background bg-primary focus-visible:ring-ring block h-4 w-4 rounded-full border-2 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className="border-background bg-primary focus-visible:ring-ring block h-4 w-4 rounded-full border-2 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
<Input
type="number"
value={formatValue(value[1])}
onChange={e => handleInputChange(1, e.target.value)}
onBlur={() => handleInputChange(1, value[1].toString())}
className="w-14"
min={min}
max={max}
step={step}
/>
</div>
);
}
);
DoubleSlider.displayName = 'DoubleSlider';
export { DoubleSlider };

View File

@@ -0,0 +1,3 @@
import { DoubleSlider } from './DoubleSlider';
export { DoubleSlider };

View File

@@ -0,0 +1,191 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons';
import { cn } from '../../lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
disabled?: boolean;
}
>(({ className, inset, children, disabled, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded px-2 py-1 text-base outline-none',
inset && 'pl-8',
disabled && 'pointer-events-none opacity-50',
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-input z-50 min-w-[8rem] overflow-hidden rounded border p-1 shadow-lg',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground border-input z-50 min-w-[8rem] overflow-hidden rounded border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded px-1 py-1 text-base outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded py-1 pl-8 pr-2 text-base outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded py-1 pl-8 pr-2 text-base outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('text-muted-foreground px-2 py-1 text-sm', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('bg-muted my-1 mx-2 h-px', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-sm tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,35 @@
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
} from './DropdownMenu';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,198 @@
import React, { useState, useEffect } from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '../Dialog/Dialog';
import { ScrollArea } from '../ScrollArea/ScrollArea';
import { Icons } from '../Icons';
const isProduction = process.env.NODE_ENV === 'production';
interface ErrorBoundaryError extends Error {
message: string;
stack?: string;
}
interface DefaultFallbackProps {
error: ErrorBoundaryError;
context: string;
resetErrorBoundary: () => void;
}
interface ErrorBoundaryProps {
context?: string;
onReset?: () => void;
onError?: (error: ErrorBoundaryError, componentStack: string, context: string) => void;
fallbackComponent?: React.ComponentType<DefaultFallbackProps>;
children: React.ReactNode;
fallbackRoute?: string | null;
isPage?: boolean;
}
const DefaultFallback = ({
error,
context,
resetErrorBoundary = () => {},
}: DefaultFallbackProps) => {
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.');
const copyErrorDetails = () => {
const errorDetails = `
Context: ${context}
Error Message: ${error.message}
Stack: ${error.stack}
`;
navigator.clipboard.writeText(errorDetails);
toast.success(t('Copied to clipboard'));
};
useEffect(() => {
toast.error(title, {
description: subtitle,
action: {
label: t('Show Details'),
onClick: () => setShowDetails(true),
},
duration: 0,
});
}, [error]);
if (isProduction) {
return null;
}
return (
<>
<Dialog
open={showDetails}
onOpenChange={setShowDetails}
>
<DialogContent className="border-input h-[50vh] w-[90vw] border-2 sm:max-w-[900px]">
<DialogHeader>
<DialogTitle className="text-xl">{title}</DialogTitle>
<DialogDescription className="text-lg">{subtitle}</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[calc(90vh-120px)]">
<div className="space-y-4 pr-4 font-mono text-base">
<div className="flex justify-end">
<button
onClick={() => {
copyErrorDetails();
setShowDetails(false);
}}
className="text-aqua-pale hover:text-aqua-pale/80 flex items-center gap-2 rounded bg-gray-800 px-4 py-2"
>
<Icons.Code className="h-4 w-4" />
{t('Copy Details')}
</button>
</div>
<div className="space-y-4">
<p className="text-aqua-pale break-words text-lg">
{t('Context')}: {context}
</p>
<p className="text-aqua-pale break-words text-lg">
{t('Error Message')}: {error.message}
</p>
<pre className="text-aqua-pale whitespace-pre-wrap break-words rounded bg-gray-900 p-4">
Stack: {error.stack}
</pre>
</div>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
</>
);
};
const ErrorBoundary = ({
context = 'OHIF',
onReset = () => {},
onError = () => {},
fallbackComponent: FallbackComponent = DefaultFallback,
children,
fallbackRoute = null,
isPage,
}: ErrorBoundaryProps) => {
const [error, setError] = useState<ErrorBoundaryError | null>(null);
const onResetHandler = () => {
setError(null);
onReset();
};
// Add error event listener to window
useEffect(() => {
let errorTimeout: NodeJS.Timeout;
const handleError = (event: ErrorEvent) => {
event.preventDefault();
clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
setError(event.error);
onErrorHandler(event.error, null);
}, 100);
};
const handleRejection = (event: PromiseRejectionEvent) => {
event.preventDefault();
clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => {
setError(event.reason);
onErrorHandler(event.reason, null);
}, 100);
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleRejection);
return () => {
clearTimeout(errorTimeout);
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleRejection);
};
}, []);
const onErrorHandler = (error: ErrorBoundaryError, componentStack: string) => {
console.debug(`${context} Error Boundary`, error, componentStack, context);
onError(error, componentStack, context);
};
return (
<ReactErrorBoundary
fallbackRender={props => (
<FallbackComponent
error={props.error}
context={context}
resetErrorBoundary={props.resetErrorBoundary}
/>
)}
onReset={onResetHandler}
onError={onErrorHandler}
>
<>
{children}
{error && (
<FallbackComponent
error={error}
context={context}
resetErrorBoundary={() => setError(null)}
/>
)}
</>
</ReactErrorBoundary>
);
};
export { ErrorBoundary };

View File

@@ -0,0 +1,3 @@
import { ErrorBoundary } from './ErrorBoundary';
export { ErrorBoundary };

View File

@@ -0,0 +1,121 @@
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
Icons,
Button,
} from '../';
import NavBar from '../NavBar';
// Todo: we should move this component to composition and remove props base
interface HeaderProps {
children?: ReactNode;
menuOptions: Array<{
title: string;
icon?: string;
onClick: () => void;
}>;
isReturnEnabled?: boolean;
onClickReturnButton?: () => void;
isSticky?: boolean;
WhiteLabeling?: {
createLogoComponentFn?: (React: any, props: any) => ReactNode;
};
PatientInfo?: ReactNode;
Secondary?: ReactNode;
}
function Header({
children,
menuOptions,
isReturnEnabled = true,
onClickReturnButton,
isSticky = false,
WhiteLabeling,
PatientInfo,
Secondary,
...props
}: HeaderProps): ReactNode {
const { t } = useTranslation('Header');
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 && <Icons.ChevronPatient className="text-primary-active w-8" />}
<div className="ml-1">
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Icons.OHIFLogo />}
</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">
{PatientInfo}
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
<div className="flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-primary-active hover:bg-primary-dark mt-2 h-full w-full"
>
<Icons.GearSettings />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{menuOptions.map((option, index) => {
const IconComponent = option.icon
? Icons[option.icon as keyof typeof Icons]
: null;
return (
<DropdownMenuItem
key={index}
onSelect={option.onClick}
className="flex items-center gap-2 py-2"
>
{IconComponent && (
<span className="flex h-4 w-4 items-center justify-center">
<IconComponent className="h-full w-full" />
</span>
)}
<span className="flex-1">{option.title}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</NavBar>
);
}
export default Header;

View File

@@ -0,0 +1,2 @@
import Header from './Header';
export { Header };

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
import { Icons } from './Icons';
export { Icons };

View File

@@ -0,0 +1,25 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'border-input text-foreground bg-background hover:bg-primary/10 placeholder:text-muted-foreground focus-visible:ring-ring flex h-7 w-full rounded border px-2 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-base file:font-medium focus-visible:outline-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,3 @@
import { Input } from './Input';
export { Input };

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const labelVariants = cva(
'text-base text-foreground font-normal leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,3 @@
import { Label } from './Label';
export { Label };

View File

@@ -0,0 +1,147 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { DataRow, PanelSection } from '../../index';
import { createContext } from '../../lib/createContext';
interface MeasurementTableContext {
data?: any[];
onClick?: (uid: string) => void;
onDelete?: (uid: string) => void;
onToggleVisibility?: (uid: string) => void;
onToggleLocked?: (uid: string) => void;
onRename?: (uid: string) => void;
onColor?: (uid: string) => void;
disableEditing?: boolean;
}
const [MeasurementTableProvider, useMeasurementTableContext] =
createContext<MeasurementTableContext>('MeasurementTable', { data: [] });
interface MeasurementDataProps extends MeasurementTableContext {
title: string;
children: React.ReactNode;
}
const MeasurementTable = ({
data = [],
onClick,
onDelete,
onToggleVisibility,
onToggleLocked,
onRename,
onColor,
title,
children,
disableEditing = false,
}: MeasurementDataProps) => {
const { t } = useTranslation('MeasurementTable');
const amount = data.length;
return (
<MeasurementTableProvider
data={data}
onClick={onClick}
onDelete={onDelete}
onToggleVisibility={onToggleVisibility}
onToggleLocked={onToggleLocked}
onRename={onRename}
onColor={onColor}
disableEditing={disableEditing}
>
<PanelSection defaultOpen={true}>
<PanelSection.Header className="bg-secondary-dark">
<span>{`${t(title)} (${amount})`}</span>
</PanelSection.Header>
<PanelSection.Content>{children}</PanelSection.Content>
</PanelSection>
</MeasurementTableProvider>
);
};
const Header = ({ children }: { children: React.ReactNode }) => {
return <div className="measurement-table-header">{children}</div>;
};
const Body = () => {
const { data } = useMeasurementTableContext('MeasurementTable.Body');
if (!data || data.length === 0) {
return (
<div className="text-primary-light mb-1 flex flex-1 items-center px-2 py-2 text-base">
No tracked measurements
</div>
);
}
return (
<div className="measurement-table-body space-y-px">
{data.map((item, index) => (
<Row
key={item.uid}
item={item}
index={index}
/>
))}
</div>
);
};
const Footer = ({ children }: { children: React.ReactNode }) => {
return <div className="measurement-table-footer">{children}</div>;
};
interface MeasurementItem {
uid: string;
label: string;
colorHex: string;
isSelected: boolean;
displayText: { primary: string[]; secondary: string[] };
isVisible: boolean;
isLocked: boolean;
toolName: string;
}
interface RowProps {
item: MeasurementItem;
index: number;
}
const Row = ({ item, index }: RowProps) => {
const {
onClick,
onDelete,
onToggleVisibility,
onToggleLocked,
onRename,
onColor,
disableEditing,
} = useMeasurementTableContext('MeasurementTable.Row');
return (
<DataRow
key={item.uid}
description={item.label}
number={index + 1}
title={item.label}
colorHex={item.colorHex}
isSelected={item.isSelected}
details={item.displayText}
onSelect={() => onClick(item.uid)}
onDelete={() => onDelete(item.uid)}
disableEditing={disableEditing}
isVisible={item.isVisible}
isLocked={item.isLocked}
onToggleVisibility={() => onToggleVisibility(item.uid)}
onToggleLocked={() => onToggleLocked(item.uid)}
onRename={() => onRename(item.uid)}
// onColor={() => onColor(item.uid)}
/>
);
};
MeasurementTable.Header = Header;
MeasurementTable.Body = Body;
MeasurementTable.Footer = Footer;
MeasurementTable.Row = Row;
export default MeasurementTable;

View File

@@ -0,0 +1,3 @@
import MeasurementTable from './MeasurementTable';
export { MeasurementTable };

View File

@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
const stickyClasses = 'sticky top-0';
const notStickyClasses = 'relative';
const NavBar = ({
className,
children,
isSticky,
}: {
className?: string;
children?: React.ReactNode;
isSticky?: boolean;
}) => {
return (
<div
className={classnames(
'bg-secondary-dark z-20 border-black px-1',
isSticky && stickyClasses,
!isSticky && notStickyClasses,
className
)}
>
{children}
</div>
);
};
NavBar.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
isSticky: PropTypes.bool,
};
export default NavBar;

View File

@@ -0,0 +1,2 @@
import NavBar from './NavBar';
export default NavBar;

View File

@@ -0,0 +1,48 @@
.shepherd-header {
@apply !bg-popover !w-[100%] !p-0;
}
.shepherd-title {
@apply !text-highlight !w-[100%] !break-words !text-xl !leading-[1.5];
}
.shepherd-content {
@apply flex flex-col gap-[8px] p-[12px];
}
.shepherd-element {
@apply !bg-popover !max-w-[260px];
}
.shepherd-text {
@apply text-foreground !w-[100%] p-0 text-lg leading-normal;
}
.shepherd-footer {
@apply !w-[100%] p-0;
}
.shepherd-button {
@apply !inline-flex !h-[36px] !min-w-[62px] !flex-row !items-center !justify-center !gap-[5px] !whitespace-nowrap !rounded !bg-[#348cfd] !px-[10px] !text-center !font-sans !text-[14px] !leading-[1.2] !text-white !outline-none !transition !duration-300 !ease-in-out focus:!outline-none;
}
.shepherd-button.shepherd-button-secondary {
@apply !bg-transparent !text-[#348cfd];
}
.shepherd-arrow::before {
@apply !bg-popover !h-[30px] !w-[30px];
}
.shepherd-element[data-popper-placement^='left'] > .shepherd-arrow {
right: 3px !important;
top: 6px !important;
}
.shepherd-element[data-popper-placement^='top'] > .shepherd-arrow {
bottom: 2px !important;
}
.shepherd-modal-overlay-container.shepherd-modal-is-visible {
@apply !opacity-70;
}

View File

@@ -0,0 +1,57 @@
import { useEffect } from 'react';
import { useShepherd } from 'react-shepherd';
import { StepOptions, TourOptions } from 'shepherd.js';
import { useLocation } from 'react-router';
import 'shepherd.js/dist/css/shepherd.css';
import './Onboarding.css';
import { hasTourBeenShown, markTourAsShown, defaultShowHandler, middleware } from './utilities';
const Onboarding = () => {
const Shepherd = useShepherd();
const location = useLocation();
const tours = window.config.tours as Array<{
id: string;
route: string;
tourOptions: TourOptions;
steps: StepOptions[];
}>;
/**
* Show the tour if it hasn't been shown yet based on the current route.
* Constructs a tour instance and adds steps to it based on the matching tour.
*/
useEffect(() => {
if (!tours) {
return;
}
const matchingTour = tours.find(tour => tour.route === location.pathname);
if (!matchingTour || hasTourBeenShown(matchingTour.id)) {
return;
}
const tourInstance = new Shepherd.Tour({
...matchingTour.tourOptions,
defaultStepOptions: {
...matchingTour.tourOptions?.defaultStepOptions,
floatingUIOptions: matchingTour.tourOptions?.defaultStepOptions?.floatingUIOptions || {
middleware,
},
when: {
...matchingTour.tourOptions?.defaultStepOptions?.when,
show:
matchingTour.tourOptions?.defaultStepOptions?.when?.show ||
(() => defaultShowHandler(Shepherd)),
},
},
});
matchingTour.steps.forEach(step => tourInstance.addStep(step));
tourInstance.start();
markTourAsShown(matchingTour.id);
}, [Shepherd, tours, location.pathname]);
return null;
};
export { Onboarding };

View File

@@ -0,0 +1,3 @@
import { Onboarding } from './Onboarding';
export { Onboarding };

View File

@@ -0,0 +1,91 @@
import { ShepherdBase } from 'shepherd.js';
import { offset, flip, shift, detectOverflow } from '@floating-ui/dom';
/**
* Retrieves the list of tours that have been shown from localStorage.
* @returns {string[]} An array of tour IDs that have been shown.
*/
const getShownTours = () => JSON.parse(localStorage.getItem('shownTours')) || [];
/**
* Checks if a specific tour has been shown.
* @param {string} tourId - The ID of the tour to check.
* @returns {boolean} True if the tour has been shown, false otherwise.
*/
const hasTourBeenShown = (tourId: string) => getShownTours().includes(tourId);
/**
* Marks a specific tour as shown by adding it to localStorage.
* @param {string} tourId - The ID of the tour to mark as shown.
* @returns {void}
*/
const markTourAsShown = (tourId: string) => {
const shownTours = getShownTours();
if (!shownTours.includes(tourId)) {
shownTours.push(tourId);
localStorage.setItem('shownTours', JSON.stringify(shownTours));
}
};
/**
* Default handler for the 'show' event in Shepherd steps.
* Adds a progress indicator to the footer of the current step.
*
* @param {ShepherdBase} Shepherd - The Shepherd.js instance.
* @returns {void}
*/
const defaultShowHandler = (Shepherd: ShepherdBase) => {
const currentStep = Shepherd.activeTour?.getCurrentStep();
if (currentStep) {
const progress = document.createElement('span');
progress.className = 'shepherd-progress text-lg text-muted-foreground';
progress.innerText = `${Shepherd.activeTour?.steps.indexOf(currentStep) + 1}/${Shepherd.activeTour?.steps.length}`;
progress.style.position = 'absolute';
progress.style.left = '13px';
progress.style.bottom = '20px';
progress.style.zIndex = '1';
const footer = currentStep?.getElement()?.querySelector('.shepherd-footer');
footer?.appendChild(progress);
}
};
/**
* Custom middleware for adjusting Shepherd step positioning when overflowing.
*
* @type {object}
* @property {string} name - The name of the middleware.
* @property {function} fn - The function that adjusts the position of the step when overflowing.
*/
const customMiddleware = {
name: 'customOverflowMiddleware',
async fn(state) {
const overflow = await detectOverflow(state, {
boundary: document.querySelector('body'),
padding: 24,
});
const xAdjustment =
overflow.left > 0 ? overflow.left : overflow.right > 0 ? -overflow.right : 0;
const yAdjustment =
overflow.top > 0 ? overflow.top : overflow.bottom > 0 ? -overflow.bottom : 0;
return {
x: state.x + xAdjustment,
y: state.y + yAdjustment,
};
},
};
/**
* Default Floating UI middleware for positioning steps in Shepherd.js.
* Includes offset, shift, flip, and custom overflow middleware.
*
* @type {Array<object>}
*/
const middleware = [offset(15), shift(), flip(), customMiddleware];
export { hasTourBeenShown, markTourAsShown, middleware, defaultShowHandler };

View File

@@ -0,0 +1,69 @@
import React from 'react';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '../Accordion/Accordion';
import { cn } from '../../lib/utils';
import { Icons } from '../Icons/Icons';
interface PanelSectionProps {
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
}
interface PanelSectionHeaderProps {
children: React.ReactNode;
className?: string;
showChevron?: boolean;
}
interface PanelSectionContentProps {
children: React.ReactNode;
className?: string;
}
export const PanelSection: React.FC<PanelSectionProps> & {
Header: React.FC<PanelSectionHeaderProps>;
Content: React.FC<PanelSectionContentProps>;
} = ({ children, defaultOpen = true, className }) => {
return (
<Accordion
type="single"
collapsible
defaultValue={defaultOpen ? 'item' : undefined}
className={cn('flex-shrink-0 overflow-hidden', className)}
>
<AccordionItem
value="item"
className="border-none"
>
{children}
</AccordionItem>
</Accordion>
);
};
PanelSection.Header = ({ children, className, showChevron = true }) => (
<AccordionTrigger
className={cn(
'bg-secondary-dark hover:bg-accent text-aqua-pale',
'my-0.5 flex h-7 w-full items-center justify-between rounded py-2 pr-1 pl-2.5 text-[13px]',
className
)}
>
{children}
</AccordionTrigger>
);
PanelSection.Header.displayName = 'PanelSection.Header';
PanelSection.Content = ({ children, className }) => (
<AccordionContent className={cn('overflow-hidden p-0', className)}>
<div className="rounded-b">{children}</div>
</AccordionContent>
);
PanelSection.Content.displayName = 'PanelSection.Content';

View File

@@ -0,0 +1,2 @@
import { PanelSection } from './PanelSection';
export { PanelSection };

View File

@@ -0,0 +1,31 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '../../lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-input z-50 w-72 rounded-md border p-4 shadow-md outline-none',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,3 @@
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './Popover';
export { Popover, PopoverContent, PopoverTrigger, PopoverAnchor };

View File

@@ -0,0 +1,120 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '../../lib/utils';
import { Icons } from '../Icons';
/**
* Props interface for the ScrollArea component.
* Extends Radix UI ScrollArea root props.
*/
interface ScrollAreaProps extends React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> {
/** Flag to show/hide scroll indicator arrows at top and bottom */
showArrows?: boolean;
}
/**
* A custom scroll area component built on top of Radix UI's ScrollArea.
* Provides a scrollable container with custom styling and optional scroll indicators.
*
* @param props - The component props
* @param props.className - Additional CSS classes to apply
* @param props.children - The content to be scrolled
* @param props.showArrows - Whether to show scroll indicator arrows
* @param ref - Forward ref for the root element
*
* @example
* ```tsx
* <ScrollArea showArrows>
* <div>Scrollable content</div>
* </ScrollArea>
* ```
*/
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
ScrollAreaProps
>(({ className, children, showArrows = false, ...props }, ref) => {
const [showBottomArrow, setShowBottomArrow] = React.useState(false);
const [showTopArrow, setShowTopArrow] = React.useState(false);
const viewportRef = React.useRef<HTMLDivElement>(null);
const checkScroll = React.useCallback(() => {
if (viewportRef.current) {
const { scrollHeight, clientHeight, scrollTop } = viewportRef.current;
setShowBottomArrow(scrollHeight > clientHeight && scrollTop < scrollHeight - clientHeight);
setShowTopArrow(scrollTop > 0);
}
}, []);
React.useEffect(() => {
checkScroll();
window.addEventListener('resize', checkScroll);
return () => window.removeEventListener('resize', checkScroll);
}, [checkScroll]);
return (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative h-full overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
className="h-full w-full rounded-[inherit]"
onScroll={checkScroll}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
{showArrows && showTopArrow && (
<div className="from-background via-background/80 pointer-events-none absolute -top-1 left-0 right-0 flex h-8 items-center justify-center bg-gradient-to-b to-transparent">
<Icons.ChevronOpen className="text-foreground/50 h-8 w-8 rotate-180" />
</div>
)}
{showArrows && showBottomArrow && (
<div className="from-background via-background/80 pointer-events-none absolute -bottom-1 left-0 right-0 flex h-8 items-center justify-center bg-gradient-to-t to-transparent">
<Icons.ChevronOpen className="text-foreground/50 h-8 w-8" />
</div>
)}
</ScrollAreaPrimitive.Root>
);
});
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
/**
* Custom scrollbar component for the ScrollArea.
* Provides styled scrollbars that can be either vertical or horizontal.
*
* @param props - The component props
* @param props.className - Additional CSS classes to apply
* @param props.orientation - The scrollbar orientation ('vertical' | 'horizontal')
* @param ref - Forward ref for the scrollbar element
*
* @example
* ```tsx
* <ScrollBar orientation="vertical" />
* ```
*/
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[2px]',
orientation === 'horizontal' && 'h-2 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-input relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,3 @@
import { ScrollArea, ScrollBar } from './ScrollArea';
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Button, Icons } from '@ohif/ui-next';
import { useSegmentationTableContext } from './SegmentationTableContext';
/**
* Props interface for the AddSegmentRow component
*/
interface AddSegmentRowProps {
/** Optional child elements to render within the row */
children?: React.ReactNode;
/** Optional segmentation object to override the active segmentation */
segmentation?: unknown;
}
/**
* A component that renders a row with controls for adding segments and toggling visibility
* in the segmentation table.
*
* @param props - Component properties
* @param props.children - Optional child elements to render within the row
* @param props.segmentation - Optional segmentation object to override the active segmentation
*/
export const AddSegmentRow: React.FC<AddSegmentRowProps> = ({ children = null, segmentation }) => {
const {
activeRepresentation,
disableEditing,
activeSegmentationId,
onSegmentAdd,
onToggleSegmentationRepresentationVisibility,
data,
showAddSegment,
} = useSegmentationTableContext('SegmentationTable');
const allSegmentsVisible = Object.values(activeRepresentation?.segments || {}).every(
segment => segment?.visible !== false
);
const segmentationIdToUse = segmentation ? segmentation.segmentationId : activeSegmentationId;
if (!data?.length) {
return null;
}
const Icon = allSegmentsVisible ? (
<Icons.Hide className="h-6 w-6" />
) : (
<Icons.Show className="h-6 w-6" />
);
const allowAddSegment = showAddSegment && !disableEditing;
return (
<div className="bg-primary-dark my-px flex h-7 w-full items-center justify-between rounded pl-0.5 pr-7">
<div className="flex-1">
{allowAddSegment ? (
<Button
size="sm"
variant="ghost"
className="pr pl-0.5"
onClick={() => onSegmentAdd(segmentationIdToUse)}
>
<Icons.Add />
Add Segment
</Button>
) : null}
</div>
<Button
size="icon"
variant="ghost"
onClick={() =>
onToggleSegmentationRepresentationVisibility(
segmentationIdToUse,
activeRepresentation?.type
)
}
>
{Icon}
</Button>
{children}
</div>
);
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Icons } from '../Icons';
import { useTranslation } from 'react-i18next';
import { useSegmentationTableContext } from './SegmentationTableContext';
export const AddSegmentationRow: React.FC<{ children?: React.ReactNode }> = ({
children = null,
}) => {
const { t } = useTranslation('SegmentationTable');
const { onSegmentationAdd, data, disableEditing, mode, disabled } =
useSegmentationTableContext('SegmentationTable');
const isEmpty = data.length === 0;
if (!isEmpty && mode === 'collapsed') {
return null;
}
if (disableEditing) {
return null;
}
return (
<div
className={`group ${disabled ? 'pointer-events-none cursor-not-allowed opacity-70' : ''}`}
onClick={() => !disabled && onSegmentationAdd('')}
>
{children}
<div className="text-primary-active group-hover:bg-secondary-dark flex items-center rounded-[4px] pl-1 group-hover:cursor-pointer">
<div className="grid h-[28px] w-[28px] place-items-center">
{disabled ? <Icons.Info /> : <Icons.Add />}
</div>
<span className="text-[13px]">
{t(`${disabled ? 'Segmentation Not Supported' : 'Add Segmentation'}`)}
</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
import React from 'react';
export const SegmentationCollapsed: React.FC<{ children?: React.ReactNode }> = ({
children = null,
}) => {
return <div>{children}</div>;
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { useSegmentationTableContext } from './SegmentationTableContext';
import { PanelSection } from '../PanelSection';
import { SegmentationHeader } from './SegmentationHeader';
import { SegmentationTable } from './SegmentationTable';
export const SegmentationExpanded: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { data } = useSegmentationTableContext('SegmentationExpanded');
// Separate the Header component from other children
const headerComponent = React.Children.toArray(children).find(
child => React.isValidElement(child) && child.type === SegmentationTable.Header
);
const otherChildren = React.Children.toArray(children).filter(
child => !(React.isValidElement(child) && child.type === SegmentationTable.Header)
);
return (
<>
{data.map(segmentationInfo => (
<PanelSection key={segmentationInfo.segmentation.segmentationId}>
<PanelSection.Header className="border-input border-t-2 bg-transparent pl-1">
{headerComponent ? (
React.cloneElement(headerComponent as React.ReactElement, {
segmentation: segmentationInfo.segmentation,
representation: segmentationInfo.representation,
})
) : (
<SegmentationHeader
segmentation={segmentationInfo.segmentation}
representation={segmentationInfo.representation}
/>
)}
</PanelSection.Header>
<PanelSection.Content>
<div className="segmentation-expanded-section">
{React.Children.map(otherChildren, child =>
React.isValidElement(child)
? React.cloneElement(child, {
segmentation: segmentationInfo.segmentation,
representation: segmentationInfo.representation,
})
: child
)}
</div>
</PanelSection.Content>
</PanelSection>
))}
</>
);
};

View File

@@ -0,0 +1,144 @@
import React from 'react';
import { Button } from '../Button';
import { Icons } from '../Icons/Icons';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuPortal,
} from '../DropdownMenu';
import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip/Tooltip';
import { useSegmentationTableContext } from './SegmentationTableContext';
import { useTranslation } from 'react-i18next';
export const SegmentationHeader: React.FC<{
segmentation?: any;
}> = ({ segmentation }) => {
const { t } = useTranslation('SegmentationTable');
const {
onSegmentAdd,
onSegmentationRemoveFromViewport,
onSegmentationEdit,
onSegmentationDelete,
onSegmentationDownload,
onSegmentationDownloadRTSS,
storeSegmentation,
} = useSegmentationTableContext('SegmentationHeader');
if (!segmentation) {
return null;
}
return (
<div className="text-foreground flex h-8 w-full items-center justify-between">
<div className="flex items-center space-x-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="ml-1"
onClick={e => e.stopPropagation()}
>
<Icons.More />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
onSegmentAdd(segmentation.segmentationId);
}}
>
<Icons.Add className="text-foreground" />
<span className="pl-2">{t('Add Segment')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
onSegmentationRemoveFromViewport(segmentation.segmentationId);
}}
>
<Icons.Series className="text-foreground" />
<span className="pl-2">{t('Remove from Viewport')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
onSegmentationEdit(segmentation.segmentationId);
}}
>
<Icons.Rename className="text-foreground" />
<span className="pl-2">{t('Rename')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={e => e.stopPropagation()}>
<Icons.Hide className="text-foreground" />
<span className="pl-2">{t('Hide or Show all Segments')}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger onClick={e => e.stopPropagation()}>
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Export & Download')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
storeSegmentation(segmentation.segmentationId);
}}
>
{t('Export DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
onSegmentationDownload(segmentation.segmentationId);
}}
>
{t('Download DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => {
e.stopPropagation();
onSegmentationDownloadRTSS(segmentation.segmentationId);
}}
>
{t('Download DICOM RTSTRUCT')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSegmentationDelete(segmentation.segmentationId)}>
<Icons.Delete className="text-red-600" />
<span className="pl-2 text-red-600">{t('Delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="pl-1.5">{segmentation.label}</div>
</div>
<div className="mr-1 flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
>
<Icons.Info className="h-6 w-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{segmentation.cachedStats.info}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
};

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { ScrollArea, DataRow } from '../../components';
import { useSegmentationTableContext } from './SegmentationTableContext';
export const SegmentationSegments: React.FC<{
segmentation?: unknown;
representation?: unknown;
}> = ({ segmentation, representation }) => {
const {
activeSegmentationId,
disableEditing,
onSegmentColorClick,
onToggleSegmentVisibility,
onToggleSegmentLock,
onSegmentClick,
mode,
onSegmentEdit,
onSegmentDelete,
data,
} = useSegmentationTableContext('SegmentationTable.Segments');
let segmentationToUse = segmentation;
let representationToUse = representation;
let segmentationIdToUse = activeSegmentationId;
if (!segmentationToUse || !representationToUse) {
const entry = data.find(seg => seg.segmentation.segmentationId === activeSegmentationId);
segmentationToUse = entry?.segmentation;
representationToUse = entry?.representation;
segmentationIdToUse = entry?.segmentation.segmentationId;
}
if (!representationToUse || !segmentationToUse) {
return null;
}
const segmentCount = Object.keys(representationToUse.segments).length;
const height = mode === 'collapsed' ? 'h-[600px]' : `h-[${segmentCount * 200}px]`;
return (
<ScrollArea
className={`ohif-scrollbar invisible-scrollbar bg-bkg-low space-y-px ${height}`}
showArrows={true}
>
{Object.values(representationToUse.segments).map(segment => {
if (!segment) {
return null;
}
const { segmentIndex, color, visible } = segment;
const segmentFromSegmentation = segmentationToUse.segments[segmentIndex];
if (!segmentFromSegmentation) {
return null;
}
const { locked, active, label, displayText } = segmentFromSegmentation;
const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`;
return (
<DataRow
key={segmentIndex}
number={segmentIndex}
title={label}
details={displayText}
colorHex={cssColor}
isSelected={active}
isVisible={visible}
isLocked={locked}
disableEditing={disableEditing}
onColor={() => onSegmentColorClick(segmentationIdToUse, segmentIndex)}
onToggleVisibility={() =>
onToggleSegmentVisibility(segmentationIdToUse, segmentIndex, representationToUse.type)
}
onToggleLocked={() => onToggleSegmentLock(segmentationIdToUse, segmentIndex)}
onSelect={() => onSegmentClick(segmentationIdToUse, segmentIndex)}
onRename={() => onSegmentEdit(segmentationIdToUse, segmentIndex)}
onDelete={() => onSegmentDelete(segmentationIdToUse, segmentIndex)}
/>
);
})}
</ScrollArea>
);
};

View File

@@ -0,0 +1,159 @@
import React from 'react';
import {
Icons,
Button,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
Tooltip,
TooltipTrigger,
TooltipContent,
DropdownMenuLabel,
} from '../../components';
import { useTranslation } from 'react-i18next';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@ohif/ui-next';
import { useSegmentationTableContext } from './SegmentationTableContext';
export const SegmentationSelectorHeader: React.FC<{ children?: React.ReactNode }> = ({
children = null,
}) => {
const { t } = useTranslation('SegmentationTable.HeaderCollapsed');
const {
data,
activeSegmentationId,
mode,
onSegmentationClick,
onSegmentationAdd,
onSegmentationRemoveFromViewport,
onSegmentationEdit,
onSegmentationDelete,
onSegmentationDownload,
onSegmentationDownloadRTSS,
storeSegmentation,
exportOptions,
} = useSegmentationTableContext('SegmentationTable.HeaderCollapsed');
if (mode !== 'collapsed' || !data?.length) {
return null;
}
const activeSegmentationObj = data.find(
seg => seg.segmentation.segmentationId === activeSegmentationId
);
const activeSegmentation = {
id: activeSegmentationObj?.segmentation.segmentationId,
label: activeSegmentationObj?.segmentation.label,
info: activeSegmentationObj?.segmentation.cachedStats?.info,
};
const segmentations = data.map(seg => ({
id: seg.segmentation.segmentationId,
label: seg.segmentation.label,
info: seg.segmentation.cachedStats?.info,
}));
const allowExport = exportOptions?.find(
({ segmentationId }) => segmentationId === activeSegmentation.id
)?.isExportable;
return (
<div className="bg-primary-dark flex h-10 w-full items-center space-x-1 rounded-t px-1.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
>
<Icons.More className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onSegmentationAdd(activeSegmentation.id)}>
<Icons.Add className="text-foreground" />
<span className="pl-2">{t('Create New Segmentation')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Manage Current Segmentation')}</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onSegmentationRemoveFromViewport(activeSegmentation.id)}>
<Icons.Series className="text-foreground" />
<span className="pl-2">{t('Remove from Viewport')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSegmentationEdit(activeSegmentation.id)}>
<Icons.Rename className="text-foreground" />
<span className="pl-2">{t('Rename')}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={!allowExport}
className="pl-1"
>
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Export & Download')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => storeSegmentation(activeSegmentation.id)}>
{t('Export DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSegmentationDownload(activeSegmentation.id)}>
{t('Download DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSegmentationDownloadRTSS(activeSegmentation.id)}>
{t('Download DICOM RTSTRUCT')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSegmentationDelete(activeSegmentation.id)}>
<Icons.Delete className="text-red-600" />
<span className="pl-2 text-red-600">{t('Delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Select
onValueChange={value => onSegmentationClick(value)}
value={activeSegmentation.id}
>
<SelectTrigger className="w-full overflow-hidden">
<SelectValue placeholder={t('Select a segmentation')} />
</SelectTrigger>
<SelectContent>
{segmentations.map(seg => (
<SelectItem
key={seg.id}
value={seg.id}
>
{seg.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
>
<Icons.Info className="h-6 w-6" />
</Button>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="end"
>
{activeSegmentation.info}
</TooltipContent>
</Tooltip>
</div>
);
};

View File

@@ -0,0 +1,76 @@
import React, { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { PanelSection } from '../PanelSection';
import { SegmentationTableProvider, SegmentationTableContext } from './SegmentationTableContext';
import { SegmentationSegments } from './SegmentationSegments';
import { SegmentationTableConfig } from './SegmentationTableConfig';
import { AddSegmentRow } from './AddSegmentRow';
import { AddSegmentationRow } from './AddSegmentationRow';
import { SegmentationSelectorHeader } from './SegmentationSelectorHeader';
import { SegmentationHeader } from './SegmentationHeader';
import { SegmentationCollapsed } from './SegmentationCollapsed';
import { SegmentationExpanded } from './SegmentationExpanded';
interface SegmentationTableProps extends SegmentationTableContext {
disabled?: boolean;
title?: string;
children?: ReactNode;
}
interface SegmentationTableComponent extends React.FC<SegmentationTableProps> {
Segments: typeof SegmentationSegments;
Config: typeof SegmentationTableConfig;
AddSegmentRow: typeof AddSegmentRow;
AddSegmentationRow: typeof AddSegmentationRow;
SelectorHeader: typeof SegmentationSelectorHeader;
Header: typeof SegmentationHeader;
Collapsed: typeof SegmentationCollapsed;
Expanded: typeof SegmentationExpanded;
}
export const SegmentationTable: SegmentationTableComponent = (props: SegmentationTableProps) => {
const { t } = useTranslation('SegmentationTable');
const { data = [], mode, title, disableEditing, disabled, children, ...contextProps } = props;
const activeSegmentationInfo = data.find(info => info.representation?.active);
const activeSegmentationId = activeSegmentationInfo?.segmentation?.segmentationId;
const activeRepresentation = activeSegmentationInfo?.representation;
const activeSegmentation = activeSegmentationInfo?.segmentation;
const { fillAlpha, fillAlphaInactive, outlineWidth, renderFill, renderOutline } =
activeRepresentation?.styles ?? {};
return (
<SegmentationTableProvider
data={data}
mode={mode}
disabled={disabled}
disableEditing={disableEditing}
fillAlpha={fillAlpha}
fillAlphaInactive={fillAlphaInactive}
outlineWidth={outlineWidth}
renderFill={renderFill}
renderOutline={renderOutline}
activeSegmentationId={activeSegmentationId}
activeSegmentation={activeSegmentation}
activeRepresentation={activeRepresentation}
{...contextProps}
>
<PanelSection defaultOpen={true}>
<PanelSection.Header>
<span>{t(title)}</span>
</PanelSection.Header>
<PanelSection.Content>{children}</PanelSection.Content>
</PanelSection>
</SegmentationTableProvider>
);
};
SegmentationTable.Segments = SegmentationSegments;
SegmentationTable.Config = SegmentationTableConfig;
SegmentationTable.AddSegmentRow = AddSegmentRow;
SegmentationTable.AddSegmentationRow = AddSegmentationRow;
SegmentationTable.SelectorHeader = SegmentationSelectorHeader;
SegmentationTable.Header = SegmentationHeader;
SegmentationTable.Collapsed = SegmentationCollapsed;
SegmentationTable.Expanded = SegmentationExpanded;

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { PanelSection } from '../PanelSection';
import { Tabs, TabsList, TabsTrigger } from '../Tabs';
import { Slider } from '../Slider';
import { Icons } from '../Icons';
import { Switch } from '../Switch';
import { Label } from '../Label';
import { Input } from '../Input';
import { useSegmentationTableContext } from './SegmentationTableContext';
export const SegmentationTableConfig: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation('SegmentationTable.AppearanceSettings');
const {
renderFill,
renderOutline,
setRenderFill,
setRenderOutline,
activeRepresentation,
fillAlpha,
fillAlphaInactive,
outlineWidth,
setFillAlpha,
setFillAlphaInactive,
setOutlineWidth,
renderInactiveSegmentations,
toggleRenderInactiveSegmentations,
data,
} = useSegmentationTableContext('styles');
if (!data?.length) {
return null;
}
return (
<PanelSection defaultOpen={false}>
<PanelSection.Header>
<div className="flex items-center">
<Icons.Settings className="mr-2 h-4 w-4" />
<span>{t('Appearance Settings')}</span>
</div>
</PanelSection.Header>
<PanelSection.Content>
<div className="bg-muted mb-0.5 space-y-2 rounded-b px-1.5 pt-0.5 pb-3">
<div className="my-1 flex items-center justify-between">
<span className="text-aqua-pale text-xs">
{t('Show')}:{' '}
{renderFill && renderOutline
? t('Fill & Outline')
: renderOutline
? t('Outline Only')
: t('Fill Only')}
</span>
<Tabs
value={
renderFill && renderOutline
? 'fill-and-outline'
: renderOutline
? 'outline'
: 'fill'
}
onValueChange={value => {
if (value === 'fill-and-outline') {
setRenderFill({ type: activeRepresentation.type }, true);
setRenderOutline({ type: activeRepresentation.type }, true);
} else if (value === 'outline') {
setRenderFill({ type: activeRepresentation.type }, false);
setRenderOutline({ type: activeRepresentation.type }, true);
} else {
setRenderFill({ type: activeRepresentation.type }, true);
setRenderOutline({ type: activeRepresentation.type }, false);
}
}}
>
<TabsList>
<TabsTrigger value="fill-and-outline">
<Icons.FillAndOutline className="text-primary-active" />
</TabsTrigger>
<TabsTrigger value="outline">
<Icons.OutlineOnly className="text-primary-active" />
</TabsTrigger>
<TabsTrigger value="fill">
<Icons.FillOnly className="text-primary-active" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="space-y-2">
<div className="my-2 flex items-center">
<Label className="text-muted-foreground w-14 flex-none whitespace-nowrap text-xs">
Opacity
</Label>
<Slider
className="mx-1 flex-1"
value={[fillAlpha]}
onValueChange={([value]) =>
setFillAlpha({ type: activeRepresentation.type }, value)
}
max={1}
min={0}
step={0.1}
/>
<Input
className="mx-1 w-10 flex-none"
value={fillAlpha}
onChange={e =>
setFillAlpha({ type: activeRepresentation.type }, Number(e.target.value))
}
/>
</div>
<div className="my-2 flex items-center">
<Label className="text-muted-foreground w-14 flex-none whitespace-nowrap text-xs">
{t('Border')}
</Label>
<Slider
value={[outlineWidth]}
onValueChange={([value]) =>
setOutlineWidth({ type: activeRepresentation.type }, value)
}
max={10}
min={0}
step={0.1}
className="mx-1 flex-1"
/>
<Input
value={outlineWidth}
onChange={e =>
setOutlineWidth({ type: activeRepresentation.type }, Number(e.target.value))
}
className="mx-1 w-10 flex-none text-center"
/>
</div>
</div>
<div className="border-input w-full border"></div>
<div className="my-2 flex items-center pl-1">
<Switch
checked={renderInactiveSegmentations}
onCheckedChange={toggleRenderInactiveSegmentations}
/>
<Label className="text-muted-foreground mx-2 text-xs">
{t('Display inactive segmentations')}
</Label>
</div>
{renderInactiveSegmentations && (
<div className="my-2 flex items-center">
<Label className="text-muted-foreground w-14 flex-none whitespace-nowrap text-xs">
Opacity
</Label>
<Slider
className="mx-1 flex-1"
value={[fillAlphaInactive]}
onValueChange={([value]) =>
setFillAlphaInactive({ type: activeRepresentation.type }, value)
}
max={1}
min={0}
step={0.1}
/>
<Input
className="mx-1 w-10 flex-none"
value={fillAlphaInactive}
onChange={e =>
setFillAlphaInactive({ type: activeRepresentation.type }, Number(e.target.value))
}
/>
</div>
)}
</div>
{children}
</PanelSection.Content>
</PanelSection>
);
};

View File

@@ -0,0 +1,80 @@
import { createContext } from '../../lib/createContext';
interface Segmentation {
segmentationId: string;
label: string;
cachedStats: {
info: string;
};
}
interface Representation {
active: boolean;
visible: boolean;
type: string;
styles: {
fillAlpha: number;
fillAlphaInactive: number;
outlineWidth: number;
renderFill: boolean;
renderOutline: boolean;
};
}
interface ViewportSegmentationInfo {
segmentation: Segmentation;
representation: Representation;
}
interface SegmentationTableContext {
data: ViewportSegmentationInfo[];
disabled: boolean;
mode: 'collapsed' | 'expanded';
fillAlpha: number;
exportOptions: {
segmentationId: string;
isExportable: boolean;
}[];
fillAlphaInactive: number;
outlineWidth: number;
renderFill: boolean;
renderOutline: boolean;
activeSegmentationId: string;
activeRepresentation: Representation;
activeSegmentation: Segmentation;
setRenderFill: ({ type }, value: boolean) => void;
setRenderOutline: ({ type }, value: boolean) => void;
setOutlineWidth: ({ type }, value: number) => void;
setFillAlpha: ({ type }, value: number) => void;
setFillAlphaInactive: ({ type }, value: number) => void;
renderInactiveSegmentations: boolean;
toggleRenderInactiveSegmentations: () => void;
onSegmentationAdd: (segmentationId: string) => void;
onSegmentationClick: (segmentationId: string) => void;
onSegmentationDelete: (segmentationId: string) => void;
onSegmentAdd: (segmentationId: string) => void;
onSegmentClick: (segmentationId: string, segmentIndex: number) => void;
onSegmentEdit: (segmentationId: string, segmentIndex: number) => void;
onSegmentationEdit: (segmentationId: string) => void;
onSegmentColorClick: (segmentationId: string, segmentIndex: number) => void;
onSegmentDelete: (segmentationId: string, segmentIndex: number) => void;
onToggleSegmentVisibility: (segmentationId: string, segmentIndex: number) => void;
onToggleSegmentLock: (segmentationId: string, segmentIndex: number) => void;
onToggleSegmentationRepresentationVisibility: (segmentationId: string, type: string) => void;
onSegmentationDownload: (segmentationId: string) => void;
storeSegmentation: (segmentationId: string) => void;
onSegmentationDownloadRTSS: (segmentationId: string) => void;
setStyle: (
segmentationId: string,
representationType: string,
styleKey: string,
value: unknown
) => void;
onSegmentationRemoveFromViewport: (segmentationId: string) => void;
disableEditing: boolean;
}
const [SegmentationTableProvider, useSegmentationTableContext] =
createContext<SegmentationTableContext>('SegmentationTable');
export { SegmentationTableProvider, useSegmentationTableContext, SegmentationTableContext };

View File

@@ -0,0 +1,22 @@
import { SegmentationTable } from './SegmentationTable';
import { SegmentationTableConfig } from './SegmentationTableConfig';
import { AddSegmentationRow } from './AddSegmentationRow';
import { AddSegmentRow } from './AddSegmentRow';
import { SegmentationSegments } from './SegmentationSegments';
import { SegmentationSelectorHeader } from './SegmentationSelectorHeader';
import { SegmentationHeader } from './SegmentationHeader';
import { useSegmentationTableContext } from './SegmentationTableContext';
SegmentationTable.Segments = SegmentationSegments;
SegmentationTable.Config = SegmentationTableConfig;
SegmentationTable.AddSegmentRow = AddSegmentRow;
SegmentationTable.AddSegmentationRow = AddSegmentationRow;
SegmentationTable.SelectorHeader = SegmentationSelectorHeader;
SegmentationTable.Header = SegmentationHeader;
export {
// context
useSegmentationTableContext,
// components
SegmentationTable,
};

View File

@@ -0,0 +1,150 @@
import * as React from 'react';
import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import * as SelectPrimitive from '@radix-ui/react-select';
import { cn } from '../../lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'border-input text-foreground ring-offset-background placeholder:text-muted-foreground focus:ring-ring [&>span]:line-clamp-1 hover:bg-primary/10 flex h-7 w-full items-center justify-between whitespace-nowrap rounded border bg-transparent px-2 py-2 text-base shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-input relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1 text-base font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded py-1 pl-2 pr-8 text-base outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,40 @@
import {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
} from './Select';
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
const SelectComponents = {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
export default SelectComponents;

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '../../lib/utils';
type SeparatorProps = React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> & {
thickness?: string;
};
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
SeparatorProps
>(
(
{ className, orientation = 'horizontal', decorative = true, thickness = '1px', ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0',
orientation === 'horizontal' ? `h-[${thickness}] w-full` : `h-full w-[${thickness}]`,
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,3 @@
import { Separator } from './Separator';
export { Separator };

View File

@@ -0,0 +1,3 @@
import { Separator } from './Separator';
export { Separator };

View File

@@ -0,0 +1,418 @@
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useState } from 'react';
import { Icons } from '../Icons';
import { TooltipTrigger, TooltipContent, TooltipProvider, Tooltip } from '../Tooltip';
import { Separator } from '../Separator';
type StyleMap = {
open: {
left: { marginLeft: string };
right: { marginRight: string };
};
closed: {
left: { marginLeft: string };
right: { marginRight: string };
};
};
const borderSize = 4;
const collapsedWidth = 25;
const closeIconWidth = 30;
const gridHorizontalPadding = 10;
const tabSpacerWidth = 2;
const baseClasses =
'transition-all duration-300 ease-in-out bg-black border-black justify-start box-content flex flex-col';
const classesMap = {
open: {
left: `mr-1`,
right: `ml-1`,
},
closed: {
left: `mr-2 items-end`,
right: `ml-2 items-start`,
},
};
const openStateIconName = {
left: 'SidePanelCloseLeft',
right: 'SidePanelCloseRight',
};
const getTabWidth = (numTabs: number) => {
if (numTabs < 3) {
return 68;
} else {
return 40;
}
};
const getGridWidth = (numTabs: number, gridAvailableWidth: number) => {
const spacersWidth = (numTabs - 1) * tabSpacerWidth;
const tabsWidth = getTabWidth(numTabs) * numTabs;
if (gridAvailableWidth > tabsWidth + spacersWidth) {
return tabsWidth + spacersWidth;
}
return gridAvailableWidth;
};
const getNumGridColumns = (numTabs: number, gridWidth: number) => {
if (numTabs === 1) {
return 1;
}
// Start by calculating the number of tabs assuming each tab was accompanied by a spacer.
const tabWidth = getTabWidth(numTabs);
const numTabsWithOneSpacerEach = Math.floor(gridWidth / (tabWidth + tabSpacerWidth));
// But there is always one less spacer than tabs, so now check if an extra tab with one less spacer fits.
if (
(numTabsWithOneSpacerEach + 1) * tabWidth + numTabsWithOneSpacerEach * tabSpacerWidth <=
gridWidth
) {
return numTabsWithOneSpacerEach + 1;
}
return numTabsWithOneSpacerEach;
};
const getTabClassNames = (
numColumns: number,
numTabs: number,
tabIndex: number,
isActiveTab: boolean,
isTabDisabled: boolean
) =>
classnames('h-[28px] mb-[2px] cursor-pointer text-white bg-black', {
'hover:text-primary-active': !isActiveTab && !isTabDisabled,
'rounded-l': tabIndex % numColumns === 0,
'rounded-r': (tabIndex + 1) % numColumns === 0 || tabIndex === numTabs - 1,
});
const getTabStyle = (numTabs: number) => {
return {
width: `${getTabWidth(numTabs)}px`,
};
};
const getTabIconClassNames = (numTabs: number, isActiveTab: boolean) => {
return classnames('h-full w-full flex items-center justify-center', {
'bg-customblue-40': isActiveTab,
rounded: isActiveTab,
});
};
const createStyleMap = (
expandedWidth: number,
borderSize: number,
collapsedWidth: number
): StyleMap => {
const collapsedHideWidth = expandedWidth - collapsedWidth - borderSize;
return {
open: {
left: { marginLeft: '0px' },
right: { marginRight: '0px' },
},
closed: {
left: { marginLeft: `-${collapsedHideWidth}px` },
right: { marginRight: `-${collapsedHideWidth}px` },
},
};
};
const getToolTipContent = (label: string, disabled: boolean) => {
return (
<>
<div>{label}</div>
{disabled && <div className="text-white">{'Not available based on current context'}</div>}
</>
);
};
const createBaseStyle = (expandedWidth: number) => {
return {
maxWidth: `${expandedWidth}px`,
width: `${expandedWidth}px`,
// To align the top of the side panel with the top of the viewport grid, use position relative and offset the
// top by the same top offset as the viewport grid. Also adjust the height so that there is no overflow.
position: 'relative',
top: '0.2%',
height: '99.8%',
};
};
const SidePanel = ({
side,
className,
activeTabIndex: activeTabIndexProp = null,
tabs,
onOpen,
onClose,
expandedWidth = 280,
onActiveTabIndexChange,
}) => {
const [panelOpen, setPanelOpen] = useState(activeTabIndexProp !== null);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const styleMap = createStyleMap(expandedWidth, borderSize, collapsedWidth);
const baseStyle = createBaseStyle(expandedWidth);
const gridAvailableWidth = expandedWidth - closeIconWidth - gridHorizontalPadding;
const gridWidth = getGridWidth(tabs.length, gridAvailableWidth);
const openStatus = panelOpen ? 'open' : 'closed';
const style = Object.assign({}, styleMap[openStatus][side], baseStyle);
const updatePanelOpen = useCallback(
(panelOpen: boolean) => {
setPanelOpen(panelOpen);
if (panelOpen && onOpen) {
onOpen();
} else if (onClose && !panelOpen) {
onClose();
}
},
[onOpen, onClose]
);
const updateActiveTabIndex = useCallback(
(activeTabIndex: number) => {
if (activeTabIndex === null) {
updatePanelOpen(false);
return;
}
setActiveTabIndex(activeTabIndex);
updatePanelOpen(true);
if (onActiveTabIndexChange) {
onActiveTabIndexChange({ activeTabIndex });
}
},
[onActiveTabIndexChange, updatePanelOpen]
);
useEffect(() => {
updateActiveTabIndex(activeTabIndexProp);
}, [activeTabIndexProp, updateActiveTabIndex]);
const getCloseStateComponent = () => {
const _childComponents = Array.isArray(tabs) ? tabs : [tabs];
return (
<>
<div
className={classnames(
'bg-secondary-dark flex h-[28px] w-full cursor-pointer items-center rounded-md',
side === 'left' ? 'justify-end pr-2' : 'justify-start pl-2'
)}
onClick={() => {
updatePanelOpen(!panelOpen);
}}
data-cy={`side-panel-header-${side}`}
>
<Icons.NavigationPanelReveal
className={classnames('text-primary-active', side === 'left' && 'rotate-180 transform')}
/>
</div>
<div className={classnames('mt-3 flex flex-col space-y-3')}>
{_childComponents.map((childComponent, index) => (
<Tooltip key={index}>
<TooltipTrigger>
<div
id={`${childComponent.name}-btn`}
data-cy={`${childComponent.name}-btn`}
className="text-primary-active hover:cursor-pointer"
onClick={() => {
return childComponent.disabled ? null : updateActiveTabIndex(index);
}}
>
{React.createElement(Icons[childComponent.iconName] || Icons.MissingIcon, {
className: classnames({
'text-primary-active': true,
'ohif-disabled': childComponent.disabled,
}),
style: {
width: '22px',
height: '22px',
},
})}
</div>
</TooltipTrigger>
<TooltipContent side={side === 'left' ? 'right' : 'left'}>
<div
className={classnames(
'flex items-center',
side === 'left' ? 'justify-end' : 'justify-start'
)}
>
{getToolTipContent(childComponent.label, childComponent.disabled)}
</div>
</TooltipContent>
</Tooltip>
))}
</div>
</>
);
};
const getCloseIcon = () => {
return (
<div
className={classnames(
'absolute flex cursor-pointer items-center justify-center',
side === 'left' ? 'right-0' : 'left-0'
)}
style={{ width: `${closeIconWidth}px` }}
onClick={() => {
updatePanelOpen(!panelOpen);
}}
data-cy={`side-panel-header-${side}`}
>
{React.createElement(Icons[openStateIconName[side]] || Icons.MissingIcon, {
className: 'text-primary-active',
})}
</div>
);
};
const getTabGridComponent = () => {
const numCols = getNumGridColumns(tabs.length, gridWidth);
return (
<>
{getCloseIcon()}
<div className={classnames('flex grow justify-center')}>
<div className={classnames('bg-primary-dark text-primary-active flex flex-wrap')}>
{tabs.map((tab, tabIndex) => {
const { disabled } = tab;
return (
<React.Fragment key={tabIndex}>
{tabIndex % numCols !== 0 && (
<div
className={classnames(
'flex h-[28px] w-[2px] items-center bg-black',
tabSpacerWidth
)}
>
<div className="bg-primary-dark h-[20px] w-full"></div>
</div>
)}
<Tooltip key={tabIndex}>
<TooltipTrigger>
<div
className={getTabClassNames(
numCols,
tabs.length,
tabIndex,
tabIndex === activeTabIndex,
disabled
)}
style={getTabStyle(tabs.length)}
onClick={() => {
return disabled ? null : updateActiveTabIndex(tabIndex);
}}
data-cy={`${tab.name}-btn`}
>
<div
className={getTabIconClassNames(tabs.length, tabIndex === activeTabIndex)}
>
{React.createElement(Icons[tab.iconName] || Icons.MissingIcon, {
className: classnames({
'text-primary-active': true,
'ohif-disabled': disabled,
}),
style: {
width: '22px',
height: '22px',
},
})}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{getToolTipContent(tab.label, disabled)}
</TooltipContent>
</Tooltip>
</React.Fragment>
);
})}
</div>
</div>
</>
);
};
const getOneTabComponent = () => {
return (
<div
className={classnames(
'text-primary-active flex grow cursor-pointer select-none justify-center self-center text-[13px]'
)}
data-cy={`${tabs[0].name}-btn`}
onClick={() => updatePanelOpen(!panelOpen)}
>
{getCloseIcon()}
<span>{tabs[0].label}</span>
</div>
);
};
const getOpenStateComponent = () => {
return (
<>
<div className="bg-bkg-med flex h-[40px] flex-shrink-0 select-none rounded-t p-2">
{tabs.length === 1 ? getOneTabComponent() : getTabGridComponent()}
</div>
<Separator
orientation="horizontal"
className="bg-black"
thickness="2px"
/>
</>
);
};
return (
<div
className={classnames(className, baseClasses, classesMap[openStatus][side])}
style={style}
>
{panelOpen ? (
<>
{getOpenStateComponent()}
{tabs.map((tab, tabIndex) => {
if (tabIndex === activeTabIndex) {
return <tab.content key={tabIndex} />;
}
return null;
})}
</>
) : (
<React.Fragment>{getCloseStateComponent()}</React.Fragment>
)}
</div>
);
};
SidePanel.propTypes = {
side: PropTypes.oneOf(['left', 'right']).isRequired,
className: PropTypes.string,
activeTabIndex: PropTypes.number,
tabs: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.shape({
iconName: PropTypes.string.isRequired,
iconLabel: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
content: PropTypes.func, // TODO: Should be node, but it keeps complaining?
})
),
]),
onOpen: PropTypes.func,
onClose: PropTypes.func,
onActiveTabIndexChange: PropTypes.func,
expandedWidth: PropTypes.number,
};
export { SidePanel };

View File

@@ -0,0 +1,2 @@
import { SidePanel } from './SidePanel';
export { SidePanel };

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '../../lib/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="bg-primary/30 relative h-1 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="border-background bg-primary focus-visible:ring-ring block h-4 w-4 rounded-full border-2 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,3 @@
import { Slider } from './Slider';
export { Slider };

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Toaster as Sonner } from 'sonner';
import { Icons } from '../Icons';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
loadingIcon={<Icons.LoadingSpinner />}
icons={{
warning: <Icons.StatusWarning />,
info: <Icons.Info className="text-secondary-foreground" />,
success: <Icons.StatusSuccess />,
error: <Icons.StatusError />,
}}
theme="dark"
richColors="true"
toastOptions={{
style: {
width: '430px', // Set a maximum width
right: '8px',
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,4 @@
import { Toaster } from './Sonner';
import { toast } from 'sonner';
export { Toaster, toast };

View File

@@ -0,0 +1,145 @@
import React from 'react';
import PropTypes from 'prop-types';
import { StudyItem } from '../StudyItem';
import { StudyBrowserSort } from '../StudyBrowserSort';
import { StudyBrowserViewOptions } from '../StudyBrowserViewOptions';
const getTrackedSeries = displaySets => {
let trackedSeries = 0;
displaySets.forEach(displaySet => {
if (displaySet.isTracked) {
trackedSeries++;
}
});
return trackedSeries;
};
const noop = () => {};
const StudyBrowser = ({
tabs,
activeTabName,
expandedStudyInstanceUIDs,
onClickTab = noop,
onClickStudy = noop,
onClickThumbnail = noop,
onDoubleClickThumbnail = noop,
onClickUntrack = noop,
activeDisplaySetInstanceUIDs,
servicesManager,
showSettings,
viewPresets,
onThumbnailContextMenu,
}: withAppTypes) => {
const getTabContent = () => {
const tabData = tabs.find(tab => tab.name === activeTabName);
const viewPreset = viewPresets
? viewPresets.filter(preset => preset.selected)[0]?.id
: 'thumbnails';
return tabData.studies.map(
({ studyInstanceUid, date, description, numInstances, modalities, displaySets }) => {
const isExpanded = expandedStudyInstanceUIDs.includes(studyInstanceUid);
return (
<React.Fragment key={studyInstanceUid}>
<StudyItem
date={date}
description={description}
numInstances={numInstances}
isExpanded={isExpanded}
displaySets={displaySets}
modalities={modalities}
trackedSeries={getTrackedSeries(displaySets)}
isActive={isExpanded}
onClick={() => {
onClickStudy(studyInstanceUid);
}}
onClickThumbnail={onClickThumbnail}
onDoubleClickThumbnail={onDoubleClickThumbnail}
onClickUntrack={onClickUntrack}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
data-cy="thumbnail-list"
viewPreset={viewPreset}
onThumbnailContextMenu={onThumbnailContextMenu}
/>
</React.Fragment>
);
}
);
};
return (
<div
className="ohif-scrollbar invisible-scrollbar bg-bkg-low flex flex-1 flex-col gap-[4px] overflow-auto pt-px"
data-cy={'studyBrowser-panel'}
>
{showSettings && (
<div className="w-100 bg-bkg-low flex h-[48px] items-center justify-center gap-[10px] px-[8px] py-[10px]">
<>
<StudyBrowserViewOptions
tabs={tabs}
onSelectTab={onClickTab}
activeTabName={activeTabName}
/>
<StudyBrowserSort servicesManager={servicesManager} />
</>
</div>
)}
{getTabContent()}
</div>
);
};
StudyBrowser.propTypes = {
onClickTab: PropTypes.func.isRequired,
onClickStudy: PropTypes.func,
onClickThumbnail: PropTypes.func,
onDoubleClickThumbnail: PropTypes.func,
onClickUntrack: PropTypes.func,
activeTabName: PropTypes.string.isRequired,
expandedStudyInstanceUIDs: PropTypes.arrayOf(PropTypes.string).isRequired,
activeDisplaySetInstanceUIDs: PropTypes.arrayOf(PropTypes.string),
tabs: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
studies: PropTypes.arrayOf(
PropTypes.shape({
studyInstanceUid: PropTypes.string.isRequired,
date: PropTypes.string,
numInstances: PropTypes.number,
modalities: PropTypes.string,
description: PropTypes.string,
displaySets: PropTypes.arrayOf(
PropTypes.shape({
displaySetInstanceUID: PropTypes.string.isRequired,
imageSrc: PropTypes.string,
imageAltText: PropTypes.string,
seriesDate: PropTypes.string,
seriesNumber: PropTypes.any,
numInstances: PropTypes.number,
description: PropTypes.string,
componentType: PropTypes.oneOf(['thumbnail', 'thumbnailTracked', 'thumbnailNoImage'])
.isRequired,
isTracked: PropTypes.bool,
/**
* Data the thumbnail should expose to a receiving drop target. Use a matching
* `dragData.type` to identify which targets can receive this draggable item.
* If this is not set, drag-n-drop will be disabled for this thumbnail.
*
* Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
*/
dragData: PropTypes.shape({
/** Must match the "type" a dropTarget expects */
type: PropTypes.string.isRequired,
}),
})
),
})
).isRequired,
})
),
};
export { StudyBrowser };

View File

@@ -0,0 +1,2 @@
import { StudyBrowser } from './StudyBrowser';
export { StudyBrowser };

View File

@@ -0,0 +1,80 @@
import React, { useEffect, useState } from 'react';
import { Icons } from '../Icons';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '../DropdownMenu/DropdownMenu';
export function StudyBrowserSort({ servicesManager }: withAppTypes) {
const { customizationService, displaySetService } = servicesManager.services;
const { values: sortFunctions } = customizationService.get('studyBrowser.sortFunctions');
const [selectedSort, setSelectedSort] = useState(sortFunctions[0]);
const [sortDirection, setSortDirection] = useState('ascending');
const handleSortChange = sortFunction => {
setSelectedSort(sortFunction);
};
const toggleSortDirection = e => {
e.stopPropagation();
setSortDirection(prevDirection => (prevDirection === 'ascending' ? 'descending' : 'ascending'));
};
useEffect(() => {
displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection);
}, [displaySetService, selectedSort, sortDirection]);
useEffect(() => {
const SubscriptionDisplaySetsChanged = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
() => {
displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection, true);
}
);
const SubscriptionDisplaySetMetaDataInvalidated = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
() => {
displaySetService.sortDisplaySets(selectedSort.sortFunction, sortDirection, true);
}
);
return () => {
SubscriptionDisplaySetsChanged.unsubscribe();
SubscriptionDisplaySetMetaDataInvalidated.unsubscribe();
};
}, [displaySetService, selectedSort, sortDirection]);
return (
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger className="border-inputfield-main focus:border-inputfield-main flex h-[26px] w-[125px] items-center justify-start rounded border bg-black p-2 text-base text-white">
{selectedSort.label}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-black">
{sortFunctions.map(sort => (
<DropdownMenuItem
key={sort.label}
className="text-white"
onClick={() => handleSortChange(sort)}
>
{sort.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={toggleSortDirection}
className="flex h-[26px] items-center justify-center bg-black"
>
{sortDirection === 'ascending' ? (
<Icons.SortingAscending className="text-primary-main w-2" />
) : (
<Icons.SortingDescending className="text-primary-main w-2" />
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { StudyBrowserSort } from './StudyBrowserSort';
export { StudyBrowserSort };

View File

@@ -0,0 +1,44 @@
import React from 'react';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '../DropdownMenu/DropdownMenu';
export function StudyBrowserViewOptions({ tabs, onSelectTab, activeTabName }: withAppTypes) {
const handleTabChange = (tabName: string) => {
onSelectTab(tabName);
};
const activeTab = tabs.find(tab => tab.name === activeTabName);
return (
<DropdownMenu>
<DropdownMenuTrigger className="border-inputfield-main focus:border-inputfield-main flex h-[26px] w-[125px] items-center justify-start rounded border bg-black p-2 text-base text-white">
{activeTab?.label}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-black">
{tabs.map(tab => {
const { name, label, studies } = tab;
const isActive = activeTabName === name;
const isDisabled = !studies.length;
if (isDisabled) {
return null;
}
return (
<DropdownMenuItem
key={name}
className={`text-white ${isActive ? 'font-bold' : ''}`}
onClick={() => handleTabChange(name)}
>
{label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,3 @@
import { StudyBrowserViewOptions } from './StudyBrowserViewOptions';
export { StudyBrowserViewOptions };

View File

@@ -0,0 +1,91 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { ThumbnailList } from '../ThumbnailList';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../Accordion';
const StudyItem = ({
date,
description,
numInstances,
modalities,
isActive,
onClick,
isExpanded,
displaySets,
activeDisplaySetInstanceUIDs,
onClickThumbnail,
onDoubleClickThumbnail,
onClickUntrack,
viewPreset = 'thumbnails',
onThumbnailContextMenu,
}: withAppTypes) => {
return (
<Accordion
type="single"
collapsible
onClick={onClick}
onKeyDown={() => {}}
className="flex-shrink-0"
role="button"
tabIndex={0}
defaultValue={isActive ? 'study-item' : undefined}
>
<AccordionItem value="study-item">
<AccordionTrigger className={classnames('hover:bg-accent bg-popover rounded')}>
<div className="flex h-[40px] flex-1 flex-row">
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-col items-start text-[13px]">
<div className="text-white">{date}</div>
<div className="text-muted-foreground h-[18px] max-w-[160px] overflow-hidden truncate whitespace-nowrap">
{description}
</div>
</div>
<div className="text-muted-foreground mr-2 flex flex-col items-end text-[12px]">
<div className="max-w-[150px] overflow-hidden text-ellipsis">{modalities}</div>
<div>{numInstances}</div>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent
onClick={event => {
event.stopPropagation();
}}
>
{isExpanded && displaySets && (
<ThumbnailList
thumbnails={displaySets}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
onThumbnailClick={onClickThumbnail}
onThumbnailDoubleClick={onDoubleClickThumbnail}
onClickUntrack={onClickUntrack}
viewPreset={viewPreset}
onThumbnailContextMenu={onThumbnailContextMenu}
/>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
};
StudyItem.propTypes = {
date: PropTypes.string.isRequired,
description: PropTypes.string,
modalities: PropTypes.string.isRequired,
numInstances: PropTypes.number.isRequired,
trackedSeries: PropTypes.number,
isActive: PropTypes.bool,
onClick: PropTypes.func.isRequired,
isExpanded: PropTypes.bool,
displaySets: PropTypes.array,
activeDisplaySetInstanceUIDs: PropTypes.array,
onClickThumbnail: PropTypes.func,
onDoubleClickThumbnail: PropTypes.func,
onClickUntrack: PropTypes.func,
viewPreset: PropTypes.string,
};
export { StudyItem };

View File

@@ -0,0 +1,2 @@
import { StudyItem } from './StudyItem';
export { StudyItem };

View File

@@ -0,0 +1,24 @@
import React from 'react';
interface StudySummaryProps {
date: string;
description: string;
}
/**
* StudySummary component displays a summary of a study with its date and description.
*
* @param props - The properties for the StudySummary component
* @param props.date - The date of the study
* @param props.description - The description of the study
*/
const StudySummary: React.FC<StudySummaryProps> = ({ date, description }) => {
return (
<div className="mx-2 my-0">
<div className="text-foreground text-sm">{date}</div>
<div className="text-muted-foreground pb-1 text-sm">{description}</div>
</div>
);
};
export { StudySummary };

View File

@@ -0,0 +1,3 @@
import { StudySummary } from './StudySummary';
export { StudySummary };

View File

@@ -0,0 +1,27 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '../../lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-primary/30 hover:data-[state=unchecked]:bg-primary/40 peer inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'bg-background pointer-events-none block h-3 w-3 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,3 @@
import { Switch } from './Switch';
export { Switch };

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '../../lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'bg-primary/20 hover:bg-primary/30 primary-foreground inline-flex h-7 items-center justify-center rounded-md py-1',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-primary/30 data-[state=active]:primary text-foreground inline-flex items-center justify-center whitespace-nowrap rounded px-2 py-1 text-base transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,3 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs"
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,3 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs';
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,4 @@
import React from 'react';
import '../../tailwind.css';
export const ThemeWrapper = ({ children }) => <React.Fragment>{children}</React.Fragment>;

View File

@@ -0,0 +1 @@
export { ThemeWrapper } from './ThemeWrapper';

View File

@@ -0,0 +1,380 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useDrag } from 'react-dnd';
import { Icons } from '../Icons';
import { DisplaySetMessageListTooltip } from '../DisplaySetMessageListTooltip';
import { TooltipTrigger, TooltipContent, Tooltip } from '../Tooltip';
import { Button } from '../Button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../DropdownMenu';
/**
* Display a thumbnail for a display set.
*/
const Thumbnail = ({
displaySetInstanceUID,
className,
imageSrc,
imageAltText,
description,
seriesNumber,
numInstances,
loadingProgress,
countIcon,
messages,
dragData = {},
isActive,
onClick,
onDoubleClick,
viewPreset = 'thumbnails',
modality,
isHydratedForDerivedDisplaySet = false,
canReject = false,
onReject = () => {},
isTracked = false,
thumbnailType = 'thumbnail',
onClickUntrack = () => {},
onThumbnailContextMenu,
}: withAppTypes): React.ReactNode => {
// TODO: We should wrap our thumbnail to create a "DraggableThumbnail", as
// this will still allow for "drag", even if there is no drop target for the
// specified item.
const [collectedProps, drag, dragPreview] = useDrag({
type: 'displayset',
item: { ...dragData },
canDrag: function (monitor) {
return Object.keys(dragData).length !== 0;
},
});
const [lastTap, setLastTap] = useState(0);
const handleTouchEnd = e => {
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTap;
if (tapLength < 300 && tapLength > 0) {
onDoubleClick(e);
} else {
onClick(e);
}
setLastTap(currentTime);
};
const renderThumbnailPreset = () => {
return (
<div
className={classnames(
'flex h-full w-full flex-col items-center justify-center gap-[2px] p-[4px]',
isActive && 'bg-popover rounded'
)}
>
<div className="h-[114px] w-[128px]">
<div className="relative">
{imageSrc ? (
<img
src={imageSrc}
alt={imageAltText}
className="h-[114px] w-[128px] rounded"
crossOrigin="anonymous"
/>
) : (
<div className="bg-background h-[114px] w-[128px] rounded"></div>
)}
{/* bottom left */}
<div className="absolute bottom-0 left-0 flex h-[14px] items-center gap-[4px] rounded-tr pt-[10px] pb-[8px] pr-[6px] pl-[3px]">
<div
className={classnames(
'h-[10px] w-[10px] rounded-[2px]',
isActive || isHydratedForDerivedDisplaySet ? 'bg-highlight' : 'bg-primary/65',
loadingProgress && loadingProgress < 1 && 'bg-primary/25'
)}
></div>
<div className="text-[11px] font-semibold text-white">{modality}</div>
</div>
{/* top right */}
<div className="absolute top-0 right-0 flex items-center gap-[4px]">
<DisplaySetMessageListTooltip
messages={messages}
id={`display-set-tooltip-${displaySetInstanceUID}`}
/>
{isTracked && (
<Tooltip>
<TooltipTrigger>
<div className="group">
<Icons.StatusTracking className="text-primary-light h-[20px] w-[20px] group-hover:hidden" />
<Icons.Cancel
className="text-primary-light hidden h-[15px] w-[15px] group-hover:block"
onClick={onClickUntrack}
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<div className="flex flex-1 flex-row">
<div className="flex-2 flex items-center justify-center pr-4">
<Icons.InfoLink className="text-primary-active" />
</div>
<div className="flex flex-1 flex-col">
<span>
<span className="text-white">
{isTracked ? 'Series is tracked' : 'Series is untracked'}
</span>
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
)}
</div>
{/* bottom right */}
<div className="absolute bottom-0 right-0 flex items-center gap-[4px] p-[4px]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden group-hover:inline-flex data-[state=open]:inline-flex"
>
<Icons.More />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
hideWhenDetached
align="start"
>
<DropdownMenuItem
onSelect={() => {
onThumbnailContextMenu('openDICOMTagViewer', {
displaySetInstanceUID,
});
}}
className="gap-[6px]"
>
<Icons.DicomTagBrowser />
Tag Browser
</DropdownMenuItem>
{canReject && (
<DropdownMenuItem
onSelect={() => {
onReject();
}}
className="gap-[6px]"
>
<Icons.Trash className="h-5 w-5 text-red-500" />
Delete Report
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div className="mt-3 flex h-[52px] w-[128px] flex-col">
<div className="min-h-[18px] w-[128px] overflow-hidden text-ellipsis pb-0.5 pl-1 text-[12px] font-normal leading-4 text-white">
{description}
</div>
<div className="flex h-[12px] items-center gap-[7px] overflow-hidden">
<div className="text-muted-foreground pl-1 text-[11px]"> S:{seriesNumber}</div>
<div className="text-muted-foreground text-[11px]">
<div className="flex items-center gap-[4px]">
{countIcon ? (
React.createElement(Icons[countIcon] || Icons.MissingIcon, { className: 'w-3' })
) : (
<Icons.InfoSeries className="w-3" />
)}
<div>{numInstances}</div>
</div>
</div>
</div>
</div>
</div>
);
};
const renderListPreset = () => {
return (
<div
className={classnames(
'flex h-full w-full items-center justify-between pr-[8px] pl-[8px] pt-[4px] pb-[4px]',
isActive && 'bg-popover rounded'
)}
>
<div className="relative flex h-[32px] items-center gap-[8px]">
<div
className={classnames(
'h-[32px] w-[4px] rounded-[2px]',
isActive || isHydratedForDerivedDisplaySet ? 'bg-highlight' : 'bg-primary/65',
loadingProgress && loadingProgress < 1 && 'bg-primary/25'
)}
></div>
<div className="flex h-full flex-col">
<div className="flex items-center gap-[7px]">
<div className="text-[13px] font-semibold text-white">{modality}</div>
<div className="max-w-[160px] overflow-hidden overflow-ellipsis whitespace-nowrap text-[13px] font-normal text-white">
{description}
</div>
</div>
<div className="flex h-[12px] items-center gap-[7px] overflow-hidden">
<div className="text-muted-foreground text-[12px]"> S:{seriesNumber}</div>
<div className="text-muted-foreground text-[12px]">
<div className="flex items-center gap-[4px]">
{' '}
{countIcon ? (
React.createElement(Icons[countIcon] || Icons.MissingIcon, { className: 'w-3' })
) : (
<Icons.InfoSeries className="w-3" />
)}
<div>{numInstances}</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex h-full items-center gap-[4px]">
<DisplaySetMessageListTooltip
messages={messages}
id={`display-set-tooltip-${displaySetInstanceUID}`}
/>
{isTracked && (
<Tooltip>
<TooltipTrigger>
<div className="group">
<Icons.StatusTracking className="text-primary-light h-[20px] w-[20px] group-hover:hidden" />
<Icons.Cancel
className="text-primary-light hidden h-[15px] w-[15px] group-hover:block"
onClick={onClickUntrack}
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<div className="flex flex-1 flex-row">
<div className="flex-2 flex items-center justify-center pr-4">
<Icons.InfoLink className="text-primary-active" />
</div>
<div className="flex flex-1 flex-col">
<span>
<span className="text-white">
{isTracked ? 'Series is tracked' : 'Series is untracked'}
</span>
</span>
</div>
</div>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden group-hover:inline-flex data-[state=open]:inline-flex"
>
<Icons.More />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent hideWhenDetached>
<DropdownMenuItem
onSelect={() => {
onThumbnailContextMenu('openDICOMTagViewer', {
displaySetInstanceUID,
});
}}
className="gap-[6px]"
>
<Icons.DicomTagBrowser />
Tag Browser
</DropdownMenuItem>
{canReject && (
<DropdownMenuItem
onSelect={() => {
onReject();
}}
className="gap-[6px]"
>
<Icons.Trash className="h-5 w-5 text-red-500" />
Delete Report
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};
return (
<div
className={classnames(
className,
'bg-muted hover:bg-primary/30 group flex cursor-pointer select-none flex-col rounded outline-none',
viewPreset === 'thumbnails' && 'h-[170px] w-[135px]',
viewPreset === 'list' && 'col-span-2 h-[40px] w-[275px]'
)}
id={`thumbnail-${displaySetInstanceUID}`}
data-cy={
thumbnailType === 'thumbnailNoImage'
? 'study-browser-thumbnail-no-image'
: 'study-browser-thumbnail'
}
data-series={seriesNumber}
onClick={onClick}
onDoubleClick={onDoubleClick}
onTouchEnd={handleTouchEnd}
role="button"
>
<div
ref={drag}
className="h-full w-full"
>
{viewPreset === 'thumbnails' && renderThumbnailPreset()}
{viewPreset === 'list' && renderListPreset()}
</div>
</div>
);
};
Thumbnail.propTypes = {
displaySetInstanceUID: PropTypes.string.isRequired,
className: PropTypes.string,
imageSrc: PropTypes.string,
/**
* Data the thumbnail should expose to a receiving drop target. Use a matching
* `dragData.type` to identify which targets can receive this draggable item.
* If this is not set, drag-n-drop will be disabled for this thumbnail.
*
* Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
*/
dragData: PropTypes.shape({
/** Must match the "type" a dropTarget expects */
type: PropTypes.string.isRequired,
}),
imageAltText: PropTypes.string,
description: PropTypes.string.isRequired,
seriesNumber: PropTypes.any,
numInstances: PropTypes.number.isRequired,
loadingProgress: PropTypes.number,
messages: PropTypes.object,
isActive: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
onDoubleClick: PropTypes.func.isRequired,
viewPreset: PropTypes.string,
modality: PropTypes.string,
isHydratedForDerivedDisplaySet: PropTypes.bool,
canReject: PropTypes.bool,
onReject: PropTypes.func,
isTracked: PropTypes.bool,
onClickUntrack: PropTypes.func,
countIcon: PropTypes.string,
thumbnailType: PropTypes.oneOf(['thumbnail', 'thumbnailTracked', 'thumbnailNoImage']),
};
export { Thumbnail };

View File

@@ -0,0 +1,2 @@
import { Thumbnail } from './Thumbnail';
export { Thumbnail };

View File

@@ -0,0 +1,112 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Thumbnail } from '../Thumbnail';
const ThumbnailList = ({
thumbnails,
onThumbnailClick,
onThumbnailDoubleClick,
onClickUntrack,
activeDisplaySetInstanceUIDs = [],
viewPreset,
onThumbnailContextMenu,
}: withAppTypes) => {
return (
<div
className="min-h-[350px]"
style={{
'--radix-accordion-content-height': '350px',
}}
>
<div
id="ohif-thumbnail-list"
className={`ohif-scrollbar bg-bkg-low grid place-items-center overflow-y-hidden pt-[4px] pr-[2.5px] pl-[2.5px] ${viewPreset === 'thumbnails' ? 'grid-cols-2 gap-[4px] pb-[12px]' : 'grid-cols-1 gap-[2px]'}`}
>
{thumbnails.map(
({
displaySetInstanceUID,
description,
dragData,
seriesNumber,
numInstances,
loadingProgress,
modality,
componentType,
countIcon,
isTracked,
canReject,
onReject,
imageSrc,
messages,
imageAltText,
isHydratedForDerivedDisplaySet,
}) => {
const isActive = activeDisplaySetInstanceUIDs.includes(displaySetInstanceUID);
return (
<Thumbnail
key={displaySetInstanceUID}
displaySetInstanceUID={displaySetInstanceUID}
dragData={dragData}
description={description}
seriesNumber={seriesNumber}
numInstances={numInstances || 1}
countIcon={countIcon}
imageSrc={imageSrc}
imageAltText={imageAltText}
messages={messages}
isActive={isActive}
modality={modality}
viewPreset={componentType === 'thumbnailNoImage' ? 'list' : viewPreset}
thumbnailType={componentType}
onClick={() => onThumbnailClick(displaySetInstanceUID)}
onDoubleClick={() => onThumbnailDoubleClick(displaySetInstanceUID)}
isTracked={isTracked}
loadingProgress={loadingProgress}
onClickUntrack={() => onClickUntrack(displaySetInstanceUID)}
isHydratedForDerivedDisplaySet={isHydratedForDerivedDisplaySet}
canReject={canReject}
onReject={onReject}
onThumbnailContextMenu={onThumbnailContextMenu}
/>
);
}
)}
</div>
</div>
);
};
ThumbnailList.propTypes = {
thumbnails: PropTypes.arrayOf(
PropTypes.shape({
displaySetInstanceUID: PropTypes.string.isRequired,
imageSrc: PropTypes.string,
imageAltText: PropTypes.string,
seriesDate: PropTypes.string,
seriesNumber: PropTypes.any,
numInstances: PropTypes.number,
description: PropTypes.string,
componentType: PropTypes.any,
isTracked: PropTypes.bool,
/**
* Data the thumbnail should expose to a receiving drop target. Use a matching
* `dragData.type` to identify which targets can receive this draggable item.
* If this is not set, drag-n-drop will be disabled for this thumbnail.
*
* Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members
*/
dragData: PropTypes.shape({
/** Must match the "type" a dropTarget expects */
type: PropTypes.string.isRequired,
}),
})
),
activeDisplaySetInstanceUIDs: PropTypes.arrayOf(PropTypes.string),
onThumbnailClick: PropTypes.func.isRequired,
onThumbnailDoubleClick: PropTypes.func.isRequired,
onClickUntrack: PropTypes.func.isRequired,
viewPreset: PropTypes.string,
};
export { ThumbnailList };

View File

@@ -0,0 +1,2 @@
import { ThumbnailList } from './ThumbnailList';
export { ThumbnailList };

View File

@@ -0,0 +1,42 @@
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-primary-foreground/80 font-medium transition-colors hover:bg-primary/20 hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-primary/20 data-[state=on]:text-highlight',
{
variants: {
variant: {
default: 'bg-transparent hover:text-primary',
outline:
'border border-input bg-transparent shadow-sm hover:bg-primary/20 hover:text-primary-foreground',
},
size: {
default: 'h-[24px] w-[28px]',
sm: 'h-8 px-2',
lg: 'h-10 px-3',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,3 @@
import { Toggle, toggleVariants } from "./Toggle";
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,3 @@
import { Toggle, toggleVariants } from './Toggle';
export { Toggle, toggleVariants };

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
import { toggleVariants } from '../Toggle';
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('bg-primary/10 flex items-center justify-center rounded-md', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

Some files were not shown because too many files have changed in this diff Show More