init
This commit is contained in:
59
platform/ui-next/src/components/Accordion/Accordion.tsx
Normal file
59
platform/ui-next/src/components/Accordion/Accordion.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Accordion/index.ts
Normal file
3
platform/ui-next/src/components/Accordion/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './Accordion';
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as BackgroundColorSelect } from './BackgroundColorSelect';
|
||||
54
platform/ui-next/src/components/Button/Button.tsx
Normal file
54
platform/ui-next/src/components/Button/Button.tsx
Normal 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 };
|
||||
1
platform/ui-next/src/components/Button/index.ts
Normal file
1
platform/ui-next/src/components/Button/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Button, buttonVariants } from './Button';
|
||||
61
platform/ui-next/src/components/Calendar/Calendar.tsx
Normal file
61
platform/ui-next/src/components/Calendar/Calendar.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Calendar/index.tsx
Normal file
3
platform/ui-next/src/components/Calendar/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Calendar } from './Calendar';
|
||||
|
||||
export { Calendar};
|
||||
75
platform/ui-next/src/components/Card/Card.tsx
Normal file
75
platform/ui-next/src/components/Card/Card.tsx
Normal 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 };
|
||||
2
platform/ui-next/src/components/Card/index.ts
Normal file
2
platform/ui-next/src/components/Card/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card';
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
26
platform/ui-next/src/components/Checkbox/Checkbox.tsx
Normal file
26
platform/ui-next/src/components/Checkbox/Checkbox.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Checkbox/index.tsx
Normal file
3
platform/ui-next/src/components/Checkbox/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Checkbox } from './Checkbox';
|
||||
|
||||
export { Checkbox };
|
||||
66
platform/ui-next/src/components/Combobox/Combobox.tsx
Normal file
66
platform/ui-next/src/components/Combobox/Combobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
platform/ui-next/src/components/Combobox/index.ts
Normal file
3
platform/ui-next/src/components/Combobox/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Combobox } from './Combobox';
|
||||
|
||||
export { Combobox};
|
||||
150
platform/ui-next/src/components/Command/Command.tsx
Normal file
150
platform/ui-next/src/components/Command/Command.tsx
Normal 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,
|
||||
};
|
||||
23
platform/ui-next/src/components/Command/index.ts
Normal file
23
platform/ui-next/src/components/Command/index.ts
Normal 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,
|
||||
};
|
||||
327
platform/ui-next/src/components/DataRow/DataRow.tsx
Normal file
327
platform/ui-next/src/components/DataRow/DataRow.tsx
Normal 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;
|
||||
3
platform/ui-next/src/components/DataRow/index.ts
Normal file
3
platform/ui-next/src/components/DataRow/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import DataRow from './DataRow';
|
||||
|
||||
export { DataRow };
|
||||
31
platform/ui-next/src/components/DataRow/types.ts
Normal file
31
platform/ui-next/src/components/DataRow/types.ts
Normal 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[];
|
||||
};
|
||||
153
platform/ui-next/src/components/DateRange/DateRange.tsx
Normal file
153
platform/ui-next/src/components/DateRange/DateRange.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
platform/ui-next/src/components/DateRange/index.ts
Normal file
3
platform/ui-next/src/components/DateRange/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { DatePickerWithRange } from './DateRange';
|
||||
|
||||
export { DatePickerWithRange };
|
||||
105
platform/ui-next/src/components/Dialog/Dialog.tsx
Normal file
105
platform/ui-next/src/components/Dialog/Dialog.tsx
Normal 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,
|
||||
};
|
||||
25
platform/ui-next/src/components/Dialog/index.ts
Normal file
25
platform/ui-next/src/components/Dialog/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip';
|
||||
|
||||
export { DisplaySetMessageListTooltip };
|
||||
120
platform/ui-next/src/components/DoubleSlider/DoubleSlider.tsx
Normal file
120
platform/ui-next/src/components/DoubleSlider/DoubleSlider.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/DoubleSlider/index.tsx
Normal file
3
platform/ui-next/src/components/DoubleSlider/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { DoubleSlider } from './DoubleSlider';
|
||||
|
||||
export { DoubleSlider };
|
||||
191
platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx
Normal file
191
platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx
Normal 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,
|
||||
};
|
||||
35
platform/ui-next/src/components/DropdownMenu/index.ts
Normal file
35
platform/ui-next/src/components/DropdownMenu/index.ts
Normal 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,
|
||||
};
|
||||
198
platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx
Normal file
198
platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Errorboundary/index.tsx
Normal file
3
platform/ui-next/src/components/Errorboundary/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
export { ErrorBoundary };
|
||||
121
platform/ui-next/src/components/Header/Header.tsx
Normal file
121
platform/ui-next/src/components/Header/Header.tsx
Normal 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;
|
||||
2
platform/ui-next/src/components/Header/index.js
Normal file
2
platform/ui-next/src/components/Header/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import Header from './Header';
|
||||
export { Header };
|
||||
2876
platform/ui-next/src/components/Icons/Icons.tsx
Normal file
2876
platform/ui-next/src/components/Icons/Icons.tsx
Normal file
File diff suppressed because one or more lines are too long
3
platform/ui-next/src/components/Icons/index.ts
Normal file
3
platform/ui-next/src/components/Icons/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Icons } from './Icons';
|
||||
|
||||
export { Icons };
|
||||
25
platform/ui-next/src/components/Input/Input.tsx
Normal file
25
platform/ui-next/src/components/Input/Input.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Input/index.tsx
Normal file
3
platform/ui-next/src/components/Input/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Input } from './Input';
|
||||
|
||||
export { Input };
|
||||
23
platform/ui-next/src/components/Label/Label.tsx
Normal file
23
platform/ui-next/src/components/Label/Label.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Label/index.tsx
Normal file
3
platform/ui-next/src/components/Label/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Label } from './Label';
|
||||
|
||||
export { Label };
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
import MeasurementTable from './MeasurementTable';
|
||||
|
||||
export { MeasurementTable };
|
||||
37
platform/ui-next/src/components/NavBar/NavBar.tsx
Normal file
37
platform/ui-next/src/components/NavBar/NavBar.tsx
Normal 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;
|
||||
2
platform/ui-next/src/components/NavBar/index.js
Normal file
2
platform/ui-next/src/components/NavBar/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import NavBar from './NavBar';
|
||||
export default NavBar;
|
||||
48
platform/ui-next/src/components/Onboarding/Onboarding.css
Normal file
48
platform/ui-next/src/components/Onboarding/Onboarding.css
Normal 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;
|
||||
}
|
||||
57
platform/ui-next/src/components/Onboarding/Onboarding.tsx
Normal file
57
platform/ui-next/src/components/Onboarding/Onboarding.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Onboarding/index.ts
Normal file
3
platform/ui-next/src/components/Onboarding/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Onboarding } from './Onboarding';
|
||||
|
||||
export { Onboarding };
|
||||
91
platform/ui-next/src/components/Onboarding/utilities.ts
Normal file
91
platform/ui-next/src/components/Onboarding/utilities.ts
Normal 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 };
|
||||
@@ -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';
|
||||
2
platform/ui-next/src/components/PanelSection/index.ts
Normal file
2
platform/ui-next/src/components/PanelSection/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { PanelSection } from './PanelSection';
|
||||
export { PanelSection };
|
||||
31
platform/ui-next/src/components/Popover/Popover.tsx
Normal file
31
platform/ui-next/src/components/Popover/Popover.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Popover/index.ts
Normal file
3
platform/ui-next/src/components/Popover/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './Popover';
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger, PopoverAnchor };
|
||||
120
platform/ui-next/src/components/ScrollArea/ScrollArea.tsx
Normal file
120
platform/ui-next/src/components/ScrollArea/ScrollArea.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/ScrollArea/index.tsx
Normal file
3
platform/ui-next/src/components/ScrollArea/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ScrollArea, ScrollBar } from './ScrollArea';
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
export const SegmentationCollapsed: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children = null,
|
||||
}) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
22
platform/ui-next/src/components/SegmentationTable/index.ts
Normal file
22
platform/ui-next/src/components/SegmentationTable/index.ts
Normal 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,
|
||||
};
|
||||
150
platform/ui-next/src/components/Select/Select.tsx
Normal file
150
platform/ui-next/src/components/Select/Select.tsx
Normal 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,
|
||||
};
|
||||
40
platform/ui-next/src/components/Select/index.tsx
Normal file
40
platform/ui-next/src/components/Select/index.tsx
Normal 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;
|
||||
33
platform/ui-next/src/components/Separator/Separator.tsx
Normal file
33
platform/ui-next/src/components/Separator/Separator.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Separator/index.ts
Normal file
3
platform/ui-next/src/components/Separator/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Separator } from './Separator';
|
||||
|
||||
export { Separator };
|
||||
3
platform/ui-next/src/components/Separator/index.tsx
Normal file
3
platform/ui-next/src/components/Separator/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Separator } from './Separator';
|
||||
|
||||
export { Separator };
|
||||
418
platform/ui-next/src/components/SidePanel/SidePanel.tsx
Normal file
418
platform/ui-next/src/components/SidePanel/SidePanel.tsx
Normal 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 };
|
||||
2
platform/ui-next/src/components/SidePanel/index.ts
Normal file
2
platform/ui-next/src/components/SidePanel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { SidePanel } from './SidePanel';
|
||||
export { SidePanel };
|
||||
23
platform/ui-next/src/components/Slider/Slider.tsx
Normal file
23
platform/ui-next/src/components/Slider/Slider.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Slider/index.tsx
Normal file
3
platform/ui-next/src/components/Slider/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Slider } from './Slider';
|
||||
|
||||
export { Slider };
|
||||
31
platform/ui-next/src/components/Sonner/Sonner.tsx
Normal file
31
platform/ui-next/src/components/Sonner/Sonner.tsx
Normal 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 };
|
||||
4
platform/ui-next/src/components/Sonner/index.ts
Normal file
4
platform/ui-next/src/components/Sonner/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Toaster } from './Sonner';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export { Toaster, toast };
|
||||
145
platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx
Normal file
145
platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx
Normal 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 };
|
||||
2
platform/ui-next/src/components/StudyBrowser/index.ts
Normal file
2
platform/ui-next/src/components/StudyBrowser/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { StudyBrowser } from './StudyBrowser';
|
||||
export { StudyBrowser };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { StudyBrowserSort } from './StudyBrowserSort';
|
||||
|
||||
export { StudyBrowserSort };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { StudyBrowserViewOptions } from './StudyBrowserViewOptions';
|
||||
|
||||
export { StudyBrowserViewOptions };
|
||||
91
platform/ui-next/src/components/StudyItem/StudyItem.tsx
Normal file
91
platform/ui-next/src/components/StudyItem/StudyItem.tsx
Normal 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 };
|
||||
2
platform/ui-next/src/components/StudyItem/index.ts
Normal file
2
platform/ui-next/src/components/StudyItem/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { StudyItem } from './StudyItem';
|
||||
export { StudyItem };
|
||||
@@ -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 };
|
||||
3
platform/ui-next/src/components/StudySummary/index.tsx
Normal file
3
platform/ui-next/src/components/StudySummary/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { StudySummary } from './StudySummary';
|
||||
|
||||
export { StudySummary };
|
||||
27
platform/ui-next/src/components/Switch/Switch.tsx
Normal file
27
platform/ui-next/src/components/Switch/Switch.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Switch/index.tsx
Normal file
3
platform/ui-next/src/components/Switch/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Switch } from './Switch';
|
||||
|
||||
export { Switch };
|
||||
53
platform/ui-next/src/components/Tabs/Tabs.tsx
Normal file
53
platform/ui-next/src/components/Tabs/Tabs.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Tabs/index.ts
Normal file
3
platform/ui-next/src/components/Tabs/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs"
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
3
platform/ui-next/src/components/Tabs/index.tsx
Normal file
3
platform/ui-next/src/components/Tabs/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs';
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
import '../../tailwind.css';
|
||||
|
||||
export const ThemeWrapper = ({ children }) => <React.Fragment>{children}</React.Fragment>;
|
||||
1
platform/ui-next/src/components/ThemeWrapper/index.ts
Normal file
1
platform/ui-next/src/components/ThemeWrapper/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ThemeWrapper } from './ThemeWrapper';
|
||||
380
platform/ui-next/src/components/Thumbnail/Thumbnail.tsx
Normal file
380
platform/ui-next/src/components/Thumbnail/Thumbnail.tsx
Normal 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 };
|
||||
2
platform/ui-next/src/components/Thumbnail/index.ts
Normal file
2
platform/ui-next/src/components/Thumbnail/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { Thumbnail } from './Thumbnail';
|
||||
export { Thumbnail };
|
||||
112
platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx
Normal file
112
platform/ui-next/src/components/ThumbnailList/ThumbnailList.tsx
Normal 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 };
|
||||
2
platform/ui-next/src/components/ThumbnailList/index.ts
Normal file
2
platform/ui-next/src/components/ThumbnailList/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { ThumbnailList } from './ThumbnailList';
|
||||
export { ThumbnailList };
|
||||
42
platform/ui-next/src/components/Toggle/Toggle.tsx
Normal file
42
platform/ui-next/src/components/Toggle/Toggle.tsx
Normal 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 };
|
||||
3
platform/ui-next/src/components/Toggle/index.ts
Normal file
3
platform/ui-next/src/components/Toggle/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Toggle, toggleVariants } from "./Toggle";
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
3
platform/ui-next/src/components/Toggle/index.tsx
Normal file
3
platform/ui-next/src/components/Toggle/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Toggle, toggleVariants } from './Toggle';
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
55
platform/ui-next/src/components/ToggleGroup/ToggleGroup.tsx
Normal file
55
platform/ui-next/src/components/ToggleGroup/ToggleGroup.tsx
Normal 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
Reference in New Issue
Block a user