feat(WorkList): New Study List (WorkList based on ui-next); old study list renamed to LegacyWorklist (#6005)

---------

Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-05-28 12:26:30 -04:00
1 parent 9e7a3586ce
commit daae4c144e
121 files changed
+8071 -1468

No files matched your search

+3 -3
View File
@@ -15,7 +15,6 @@
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"start": "yarn run build --watch",
"dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.playground.js",
"test": "echo \"Error: no test specified\" && exit 1",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage",
@@ -29,6 +28,7 @@
".": "./src/index.ts"
},
"dependencies": {
"@tanstack/react-table": "8.21.3",
"@radix-ui/react-accordion": "1.2.11",
"@radix-ui/react-checkbox": "1.3.2",
"@radix-ui/react-context-menu": "2.2.15",
@@ -51,12 +51,12 @@
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"date-fns": "3.6.0",
"date-fns": "4.1.0",
"framer-motion": "6.2.4",
"lucide-react": "0.379.0",
"next-themes": "0.3.0",
"react": "18.3.1",
"react-day-picker": "8.10.1",
"react-day-picker": "9.12.0",
"react-resizable-panels": "2.1.9",
"react-shepherd": "6.1.1",
"shepherd.js": "13.0.3",
@@ -0,0 +1,38 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-xl border px-1 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary/25 text-primary shadow hover:bg-primary/35',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
@@ -0,0 +1 @@
export { Badge, badgeVariants, type BadgeProps } from './Badge';
@@ -1,10 +1,9 @@
import * as React from 'react';
import { useMemo } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
import type { Locale } from 'react-day-picker';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import type { Locale } from 'date-fns';
import {
ar as arLocale,
ca as caLocale,
@@ -18,15 +17,12 @@ import {
tr as trLocale,
vi as viLocale,
zhCN as zhLocale,
} from 'date-fns/locale';
} from 'react-day-picker/locale';
import { cn } from '../../lib/utils';
import { Button, buttonVariants } from '../Button';
import { buttonVariants } from '../Button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
const DATE_FNS_LOCALE_MAP: Record<string, Locale> = {
const LOCALE_MAP: Record<string, Locale> = {
en: enUS,
'en-US': enUS,
fr: frLocale,
@@ -49,65 +45,206 @@ const DATE_FNS_LOCALE_MAP: Record<string, Locale> = {
'test-LNG': enUS,
};
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
const { i18n, t } = useTranslation('DatePicker');
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
locale: localeProp,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const { i18n } = useTranslation('DatePicker');
const defaultClassNames = getDefaultClassNames();
const locale = useMemo(() => {
if (localeProp) {
return localeProp;
}
const lang = i18n.language || 'en';
return DATE_FNS_LOCALE_MAP[lang] ?? enUS;
}, [i18n.language]);
return LOCALE_MAP[lang] ?? enUS;
}, [i18n.language, localeProp]);
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
captionLayout="dropdown"
fromYear={1945}
toYear={new Date().getFullYear()}
labels={{
labelMonthDropdown: () => undefined,
labelYearDropdown: () => undefined,
}}
locale={locale}
className={cn(
'bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatCaption: month => format(month, 'LLLL yyyy', { locale }),
formatMonthDropdown: date => date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
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] uppercase',
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'
root: cn('w-fit min-w-[255px]', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn(
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav
),
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',
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-[--cell-size] select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-[--cell-size] select-none p-0 aria-disabled:opacity-50',
defaultClassNames.button_next
),
month_caption: cn(
'flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]',
defaultClassNames.month_caption
),
dropdowns: cn(
'flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns
),
dropdown_root: cn(
'relative rounded-md border border-input',
defaultClassNames.dropdown_root
),
dropdown: cn('absolute inset-0 opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: '[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5',
defaultClassNames.caption_label
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground flex-1 rounded-md text-[0.8rem] font-normal',
defaultClassNames.weekday
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn('w-[--cell-size] select-none', defaultClassNames.week_number_header),
week_number: cn(
'text-muted-foreground select-none text-[0.8rem]',
defaultClassNames.week_number
),
day: cn(
'group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
defaultClassNames.day
),
range_start: cn('bg-accent rounded-l-md', defaultClassNames.range_start),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('bg-accent rounded-r-md', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside
),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon
className={cn('size-4', className)}
{...props}
/>
);
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
);
}
return (
<ChevronDownIcon
className={cn('size-4', className)}
{...props}
/>
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="size-[--cell-size] flex items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) {
ref.current?.focus();
}
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
@@ -1,3 +1,3 @@
import { Calendar } from './Calendar';
import { Calendar, CalendarDayButton } from './Calendar';
export { Calendar};
export { Calendar, CalendarDayButton };
@@ -0,0 +1,138 @@
import * as React from 'react';
import type { Cell } from '@tanstack/react-table';
import type { ColumnMeta } from './types';
// Context to share computed values with sub-components
type ActionOverlayCellContextValue = {
isActive: boolean;
computedAlign: 'start' | 'center' | 'end';
cell: Cell<unknown, unknown>;
};
const ActionOverlayCellContext = React.createContext<ActionOverlayCellContextValue | null>(null);
// Symbol to identify sub-components
const VALUE_TYPE = Symbol('ActionOverlayCell.Value');
const OVERLAY_TYPE = Symbol('ActionOverlayCell.Overlay');
type ActionOverlayCellProps<TData> = {
cell: Cell<TData, unknown>;
children?: React.ReactNode;
};
export function ActionOverlayCell<TData>({ cell, children }: ActionOverlayCellProps<TData>) {
const isActive = cell.row.getIsSelected();
const meta = (cell.column.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const align = meta?.align ?? 'right';
// Map 'left' | 'center' | 'right' to 'start' | 'center' | 'end' for overlay positioning
const computedAlign = align === 'left' ? 'start' : align === 'center' ? 'center' : 'end';
const contextValue: ActionOverlayCellContextValue = {
isActive,
computedAlign,
cell: cell as Cell<unknown, unknown>,
};
// Extract Value and Overlay components from children
// Only Value and Overlay sub-components are recognized; other children are ignored
let valueElement: React.ReactElement | null = null;
let overlayElement: React.ReactElement | null = null;
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
const childType = (child.type as { _type?: symbol })?._type;
if (childType === VALUE_TYPE) {
valueElement = child;
} else if (childType === OVERLAY_TYPE) {
overlayElement = child;
}
}
});
return (
<ActionOverlayCellContext.Provider value={contextValue}>
<div className="relative">
{valueElement}
{overlayElement}
</div>
</ActionOverlayCellContext.Provider>
);
}
// Value sub-component
type ValueProps = {
children?: React.ReactNode;
};
function Value({ children }: ValueProps) {
const context = React.useContext(ActionOverlayCellContext);
if (!context) {
throw new Error('ActionOverlayCell.Value must be used within ActionOverlayCell');
}
const { isActive, computedAlign } = context;
const valueAlignmentClass =
computedAlign === 'end' ? 'text-right' : computedAlign === 'center' ? 'text-center' : '';
const valueVisibilityClass = isActive
? 'invisible opacity-0'
: 'group-hover:invisible group-hover:opacity-0 group-hover:text-transparent';
return (
<div className={`transition-opacity ${valueAlignmentClass} ${valueVisibilityClass}`}>
{children}
</div>
);
}
// Mark Value component with symbol for identification
(Value as { _type?: symbol })._type = VALUE_TYPE;
// Overlay sub-component
type OverlayProps = {
children?: React.ReactNode;
};
function Overlay({ children }: OverlayProps) {
const context = React.useContext(ActionOverlayCellContext);
if (!context) {
throw new Error('ActionOverlayCell.Overlay must be used within ActionOverlayCell');
}
const { isActive, computedAlign, cell } = context;
const overlayPositionClass =
computedAlign === 'center'
? 'inset-y-0 inset-x-0 justify-center px-2'
: computedAlign === 'start'
? 'inset-y-0 left-0 px-2'
: 'inset-y-0 right-0 px-2';
const overlayVisibilityClass = isActive
? 'bg-popover opacity-100'
: 'opacity-0 group-hover:bg-muted group-hover:opacity-100';
return (
<div
className={`absolute z-10 flex items-center ${overlayPositionClass} ${overlayVisibilityClass}`}
onMouseDown={e => {
e.stopPropagation();
if (!cell.row.getIsSelected()) {
cell.row.toggleSelected(true);
}
}}
onPointerDown={e => {
e.stopPropagation();
if (!cell.row.getIsSelected()) {
cell.row.toggleSelected(true);
}
}}
>
{children}
</div>
);
}
// Mark Overlay component with symbol for identification
(Overlay as { _type?: symbol })._type = OVERLAY_TYPE;
// Export as compound component
ActionOverlayCell.Value = Value;
ActionOverlayCell.Overlay = Overlay;
@@ -0,0 +1,46 @@
import * as React from 'react';
import type { Column } from '@tanstack/react-table';
import { Button } from '../Button';
import { Icons } from '../Icons';
import type { ColumnMeta } from './types';
export function ColumnHeader<TData, TValue>({ column }: { column: Column<TData, TValue> }) {
const meta = (column.columnDef.meta as ColumnMeta | undefined) ?? undefined;
// Use headerContent if provided, otherwise use label
const content = meta?.headerContent ?? meta?.label ?? column.id;
const align = meta?.align ?? 'left';
const canSort = column.getCanSort();
const sorted = column.getIsSorted() as false | 'asc' | 'desc';
const justify =
align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start';
const SortIcon =
sorted === 'asc'
? Icons.SortingNewAscending
: sorted === 'desc'
? Icons.SortingNewDescending
: Icons.SortingNew;
const ariaLabel = meta?.label ?? column.id;
return (
<div className={`flex w-full items-center gap-1 ${justify}`}>
{typeof content === 'string' ? <span>{content}</span> : content}
{canSort && (
<Button
variant="ghost"
size="sm"
onClick={() => column.toggleSorting(sorted === 'asc')}
aria-label={`Sort ${ariaLabel}`}
className="px-1"
>
<SortIcon
className="h-4 w-2.5"
aria-hidden="true"
/>
</Button>
)}
</div>
);
}
@@ -0,0 +1,455 @@
import React, {
type ReactNode,
type ReactElement,
useState,
useCallback,
useEffect,
useRef,
Children,
isValidElement,
} from 'react';
import { useTranslation } from 'react-i18next';
import type {
ColumnDef,
ColumnFiltersState,
RowSelectionState,
SortingState,
VisibilityState,
PaginationState,
Row,
} from '@tanstack/react-table';
import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
useReactTable,
flexRender,
} from '@tanstack/react-table';
import { DataTableContext, DataTableContextValue, useDataTable } from './context';
import { Toolbar } from './Toolbar';
import { Title } from './Title';
import { Pagination } from './Pagination';
import { ViewOptions } from './ViewOptions';
import { ActionOverlayCell } from './ActionOverlayCell';
import { FilterRow } from './FilterRow';
import { ColumnHeader } from './ColumnHeader';
import { ResponsiveColumnsProvider, useResponsiveColumns } from './useResponsiveColumns';
import type { ColumnMeta } from './types';
import {
Table as BasicTable,
TableHeader as BasicTableHeader,
TableBody as BasicTableBody,
TableHead as BasicTableHead,
TableRow as BasicTableRow,
TableCell as BasicTableCell,
} from '../Table';
import { ScrollArea } from '../ScrollArea';
import { cn } from '../../lib/utils';
// Type for state update functions that accept either a value or an updater function
type Updater<T> = T | ((prev: T) => T);
type OnChangeFn<T> = (updater: Updater<T>) => void;
export type DataTableProps<TData> = {
data: TData[];
columns: ColumnDef<TData, unknown>[];
getRowId?: (row: TData, index: number) => string;
initialVisibility?: VisibilityState;
sorting?: SortingState;
pagination?: PaginationState;
filters?: ColumnFiltersState;
onSortingChange?: OnChangeFn<SortingState>;
onPaginationChange?: OnChangeFn<PaginationState>;
onFiltersChange?: OnChangeFn<ColumnFiltersState>;
manualFiltering?: boolean;
enforceSingleSelection?: boolean;
onSelectionChange?: (rows: TData[]) => void;
children: ReactNode;
};
/**
* Root DataTable provider component.
* Creates the TanStack table instance, manages state, and exposes it via context.
*/
function DataTableRoot<TData>({
data,
columns,
getRowId,
initialVisibility = {},
sorting = [],
pagination = { pageIndex: 0, pageSize: 50 },
filters = [],
onSortingChange,
onPaginationChange,
onFiltersChange,
manualFiltering = false,
enforceSingleSelection = true,
onSelectionChange,
children,
}: DataTableProps<TData>) {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(initialVisibility);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useReactTable<TData>({
data,
columns,
state: { sorting, columnVisibility, rowSelection, columnFilters: filters, pagination },
onSortingChange: onSortingChange,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onColumnFiltersChange: onFiltersChange,
onPaginationChange: onPaginationChange,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualFiltering,
enableRowSelection: true,
enableMultiRowSelection: !enforceSingleSelection,
getRowId,
autoResetPageIndex: false,
});
// Reset pagination to page 0 when filters or sorting change, but only when
// pagination itself did not change in the same render. A simultaneous
// pagination change means the caller set the page intentionally (e.g. a
// coordinated state restore), so we leave it alone.
// The refs are initialized to the mount-time values so the first effect run
// always sees all three as unchanged — ensuring that
// any pagination applied with filtering or sorting during the initial render
// is not overridden.
const prevFiltersRef = useRef(filters);
const prevSortingRef = useRef(sorting);
const prevPaginationRef = useRef(pagination);
useEffect(() => {
const filtersChanged = filters !== prevFiltersRef.current;
const sortingChanged = sorting !== prevSortingRef.current;
const paginationChanged = pagination !== prevPaginationRef.current;
prevFiltersRef.current = filters;
prevSortingRef.current = sorting;
prevPaginationRef.current = pagination;
if ((filtersChanged || sortingChanged) && !paginationChanged && onPaginationChange) {
onPaginationChange(p => ({ ...p, pageIndex: 0 }));
}
}, [filters, sorting, pagination, onPaginationChange]);
// Deselect rows that are no longer on the current page (after filters +
// pagination). Without this, a selected row that gets filtered or paged out
// remains in rowSelection — keeping downstream consumers (e.g. a preview
// panel) pinned to a study the user can't see.
useEffect(() => {
const selectedIds = Object.keys(rowSelection);
if (selectedIds.length === 0) {
return;
}
const visibleIds = new Set(table.getPaginationRowModel().rows.map(r => r.id));
if (selectedIds.every(id => visibleIds.has(id))) {
return;
}
setRowSelection(prev => {
const next: RowSelectionState = {};
for (const id of Object.keys(prev)) {
if (visibleIds.has(id)) {
next[id] = prev[id];
}
}
return next;
});
}, [filters, sorting, pagination, data, table, rowSelection]);
// Surface selection changes to consumers.
useEffect(() => {
if (!onSelectionChange) {
return;
}
const selected = table.getSelectedRowModel().rows.map(r => r.original as TData);
onSelectionChange(selected);
}, [rowSelection, onSelectionChange, table]);
return (
<DataTableContext.Provider value={{ table } as DataTableContextValue<unknown>}>
<ResponsiveColumnsProvider>{children}</ResponsiveColumnsProvider>
</DataTableContext.Provider>
);
}
type TableProps = {
children?: ReactNode;
/**
* Optional className applied to the outer bordered container.
*/
className?: string;
/**
* Optional className applied to the underlying <table> in both header and body tables.
*/
tableClassName?: string;
};
/**
* Layout shell that renders:
* - A header table (column headers + optional filter row).
* - A scrollable body table.
*
* Consumers pass <DataTable.Header />, <DataTable.FilterRow />, and <DataTable.Body />
* as children; this component wires them into the correct structure.
*/
function Table<TData>({ children, className, tableClassName }: TableProps) {
const { table } = useDataTable<TData>();
const wrapperRef = useRef<HTMLDivElement>(null);
// Drive responsive column visibility from the wrapper's width. The hook
// is a no-op for tables whose columns don't declare meta.priority, and
// publishes the "unfit" set through ResponsiveColumnsProvider (rendered
// by DataTableRoot above us in the tree).
useResponsiveColumns(table, wrapperRef);
const rows =
typeof table.getPaginationRowModel === 'function'
? table.getPaginationRowModel().rows
: table.getRowModel().rows;
const isEmpty = rows.length === 0;
const renderColGroup = useCallback(
() => (
<colgroup>
{table.getVisibleLeafColumns().map(col => {
const meta = (col.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const minWidth = meta?.minWidth;
return minWidth ? (
<col
key={col.id}
style={{
width: typeof minWidth === 'number' ? `${minWidth}px` : minWidth,
}}
/>
) : (
<col key={col.id} />
);
})}
</colgroup>
),
[table]
);
let headerChild: ReactElement | null = null;
let filterRowChild: ReactElement | null = null;
let bodyChild: ReactElement | null = null;
Children.forEach(children, child => {
if (!isValidElement(child)) {
return;
}
if (child.type === Header) {
headerChild = child;
}
if (child.type === FilterRow) {
filterRowChild = child;
}
if (child.type === Body) {
bodyChild = child;
}
});
return (
<div
ref={wrapperRef}
className={cn('border-input/50 min-h-0 flex-1 rounded-md border', className)}
>
<div className="flex h-full flex-col">
{/* Header + filter row */}
<div className="border-input/50 shrink-0 border-b">
<BasicTable
className={cn('table-fixed', tableClassName)}
containerClassName="overflow-x-hidden"
noScroll
>
{renderColGroup()}
{headerChild}
<BasicTableBody>{filterRowChild}</BasicTableBody>
</BasicTable>
</div>
{/* Scrollable body */}
<div className="min-h-0 flex-1">
<ScrollArea className={cn('h-full', isEmpty && '[&>div>div]:!h-full')}>
<BasicTable
className={cn('h-full table-fixed', tableClassName)}
containerClassName="h-full"
noScroll
>
{renderColGroup()}
<BasicTableBody>{bodyChild}</BasicTableBody>
</BasicTable>
</ScrollArea>
</div>
</div>
</div>
);
}
/**
* Renders the table header row(s) based on the current table instance.
* Applies meta.headerClassName and a muted background to match StudyList styling.
*/
function Header<TData>() {
const { table } = useDataTable<TData>();
return (
<BasicTableHeader>
{table.getHeaderGroups().map(headerGroup => (
<BasicTableRow key={headerGroup.id}>
{headerGroup.headers.map(header => {
const meta = (header.column.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const headerClassName = meta?.headerClassName ?? '';
const sortState = header.column.getIsSorted() as false | 'asc' | 'desc';
return (
<BasicTableHead
key={header.id}
className={cn('bg-muted', headerClassName)}
aria-sort={
sortState === 'asc' ? 'ascending' : sortState === 'desc' ? 'descending' : 'none'
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</BasicTableHead>
);
})}
</BasicTableRow>
))}
</BasicTableHeader>
);
}
type RowProps<TData> = {
render?: (row: Row<TData>) => ReactNode;
onClick?: (row: Row<TData>) => void;
onDoubleClick?: (row: Row<TData>) => void;
className?: string | ((row: Row<TData>) => string);
};
type BodyProps<TData> = {
rowProps?: RowProps<TData>;
/**
* Message shown when there are no rows to render.
*/
emptyMessage?: string;
/**
* When true and there are no rows, show loadingComponent centered instead of emptyMessage.
*/
isLoading?: boolean;
/**
* Rendered in the empty body when isLoading is true (e.g. customization loading indicator).
*/
loadingComponent?: ReactNode;
};
/**
* Core body renderer. Keeps awareness of selection state via data-state="selected".
* Automatically uses pagination if getPaginationRowModel is configured on the table.
* Consumers can either rely on the default row renderer or provide a custom one.
*/
function Body<TData>({
rowProps,
emptyMessage,
isLoading,
loadingComponent,
}: BodyProps<TData>) {
const { t } = useTranslation('DataTable');
const resolvedEmptyMessage = emptyMessage ?? t('No results.');
const { table } = useDataTable<TData>();
// Automatically determine if pagination should be used
// Use pagination if getPaginationRowModel is defined (pagination is configured)
const rows =
typeof table.getPaginationRowModel === 'function'
? table.getPaginationRowModel().rows
: table.getRowModel().rows;
if (!rows.length) {
if (isLoading && loadingComponent) {
return (
<BasicTableRow className="hover:bg-transparent hover:text-inherit hover:[&>td]:text-inherit hover:[&>th]:text-inherit">
<BasicTableCell colSpan={table.getAllLeafColumns().length}>
<div className="flex h-full w-full items-center justify-center">{loadingComponent}</div>
</BasicTableCell>
</BasicTableRow>
);
}
return (
<BasicTableRow className="hover:bg-transparent hover:text-inherit hover:[&>td]:text-inherit hover:[&>th]:text-inherit">
<BasicTableCell
colSpan={table.getAllLeafColumns().length}
className="!pt-10 text-center align-text-top"
>
{resolvedEmptyMessage}
</BasicTableCell>
</BasicTableRow>
);
}
return (
<>
{rows.map(row => {
const customRender = rowProps?.render?.(row);
if (customRender) {
return customRender;
}
// Default row rendering
return (
<BasicTableRow
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
className={
rowProps?.className
? typeof rowProps.className === 'function'
? rowProps.className(row)
: rowProps.className
: ''
}
{...(rowProps?.onClick && { onClick: () => rowProps.onClick(row) })}
{...(rowProps?.onDoubleClick && {
onDoubleClick: () => rowProps.onDoubleClick(row),
})}
aria-selected={row.getIsSelected()}
>
{row.getVisibleCells().map(cell => {
const metaClass =
(cell.column.columnDef.meta as ColumnMeta | undefined)?.cellClassName ?? '';
return (
<BasicTableCell
key={cell.id}
className={metaClass}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</BasicTableCell>
);
})}
</BasicTableRow>
);
})}
</>
);
}
const DataTable = Object.assign(DataTableRoot, {
Toolbar,
Title,
Pagination,
ViewOptions,
Table,
Header,
FilterRow,
Body,
ColumnHeader,
ActionOverlayCell,
});
export { DataTable };
@@ -0,0 +1,52 @@
import * as React from 'react';
import { TableRow, TableCell } from '../Table';
import { Input } from '../Input';
import { useDataTable } from './context';
export type FilterRowProps = {
excludeColumnIds?: string[];
renderFilterCell?: (opts: {
columnId: string;
value: unknown;
setValue: (v: unknown) => void;
}) => React.ReactNode;
};
export function FilterRow<TData>({ excludeColumnIds = [], renderFilterCell }: FilterRowProps) {
const { table } = useDataTable<TData>();
const cols = table.getVisibleLeafColumns();
return (
<TableRow
data-filter-row
className="hover:bg-transparent"
>
{cols.map(col => {
const id = col.id;
const value = table.getColumn(id)?.getFilterValue();
const setValue = (v: unknown) => table.getColumn(id)?.setFilterValue(v);
if (excludeColumnIds?.includes(id)) {
return <TableCell key={id} />;
}
const customRender = renderFilterCell?.({ columnId: id, value, setValue });
if (customRender) {
return <TableCell key={id}>{customRender}</TableCell>;
}
// Default cell rendering
return (
<TableCell key={id}>
<Input
value={(value as string) ?? ''}
onChange={e => setValue(e.target.value)}
className="h-7 w-full"
/>
</TableCell>
);
})}
</TableRow>
);
}
@@ -0,0 +1,78 @@
import * as React from 'react';
import { Button } from '../Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '../DropdownMenu';
import { Icons } from '../Icons';
import { useDataTable } from './context';
/**
* Pagination
* Renders "start-end of total" and ghost chevron buttons for prev/next.
* Uses the TanStack table instance from DataTable context.
*/
export function Pagination<TData>() {
const { table } = useDataTable<TData>();
const { pageIndex, pageSize } = table.getState().pagination ?? { pageIndex: 0, pageSize: 50 };
const total = table.getFilteredRowModel().rows.length;
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min(total, (pageIndex + 1) * pageSize);
const canPrev = table.getCanPreviousPage();
const canNext = table.getCanNextPage();
return (
<div className="mr-2 flex items-center gap-0.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-primary/80 px-2 text-sm leading-tight"
aria-label="Rows per page"
>
{`${start}-${end} of ${total}`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{[25, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onSelect={e => {
e.preventDefault();
table.setPageSize(size);
}}
className="flex items-center gap-[2px]"
>
<Icons.Checked className={`h-6 w-6 ${pageSize === size ? '' : 'invisible'}`} />
{size} per page
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
aria-label="Previous page"
onClick={() => table.previousPage()}
disabled={!canPrev}
className="ml-1"
>
<Icons.ChevronLeft className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Next page"
onClick={() => table.nextPage()}
disabled={!canNext}
>
<Icons.ChevronRight className="h-3 w-3" />
</Button>
</div>
);
}
@@ -0,0 +1,5 @@
import * as React from 'react';
export function Title({ children }: { children?: React.ReactNode }) {
return <div className="text-primary text-[20px] font-medium">{children}</div>;
}
@@ -0,0 +1,5 @@
import * as React from 'react';
export function Toolbar({ children }: { children?: React.ReactNode }) {
return <div className="relative flex items-center justify-center py-4">{children}</div>;
}
@@ -0,0 +1,76 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuCheckboxItem,
} from '../DropdownMenu';
import { Icons } from '../Icons';
import { Tooltip, TooltipContent, TooltipTrigger } from '../Tooltip';
import { useDataTable } from './context';
import { useUnfitColumnIds } from './useResponsiveColumns';
import type { ColumnMeta } from './types';
type ViewOptionsProps = {
buttonText?: string;
};
export function ViewOptions<TData>({ buttonText = 'View' }: ViewOptionsProps) {
const { t } = useTranslation('DataTable');
const { table } = useDataTable<TData>();
const unfitColumnIds = useUnfitColumnIds();
const columns = table.getAllColumns().filter(c => c.getCanHide());
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1 text-sm"
>
{buttonText}
<Icons.ChevronDown className="h-2 w-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{columns.map(column => {
const meta = (column.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const label = meta?.label ?? column.id;
const isUnfit = !column.getIsVisible() && unfitColumnIds.has(column.id);
const checkbox = (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
disabled={isUnfit}
onCheckedChange={v => column.toggleVisibility(!!v)}
className="capitalize"
>
{label}
</DropdownMenuCheckboxItem>
);
// Radix tooltips don't fire on a disabled descendant (no pointer
// events). Wrap in a span — matching the ToolButton pattern in this
// package — so the trigger element itself is enabled.
if (isUnfit) {
return (
<Tooltip key={column.id}>
<TooltipTrigger asChild>
<span>{checkbox}</span>
</TooltipTrigger>
<TooltipContent side="left">
{t('Not enough room to display this column')}
</TooltipContent>
</Tooltip>
);
}
return <React.Fragment key={column.id}>{checkbox}</React.Fragment>;
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react';
import type { Table } from '@tanstack/react-table';
export type DataTableContextValue<TData> = {
table: Table<TData>;
};
// React Context cannot be generic, so we use 'unknown' as the base type
// The generic type is properly restored by useDataTable<TData>() via type assertion
const DataTableContext = createContext<DataTableContextValue<unknown> | null>(null);
export function useDataTable<TData>() {
const ctx = useContext(DataTableContext);
if (!ctx) {
throw new Error('useDataTable must be used within a <DataTable> provider');
}
return ctx as DataTableContextValue<TData>;
}
export { DataTableContext };
@@ -0,0 +1,4 @@
// Core compound table + hook
export { DataTable } from './DataTable';
export type { DataTableProps } from './DataTable';
export { useDataTable } from './context';
@@ -0,0 +1,27 @@
import * as React from 'react';
/**
* Metadata type for DataTable columns.
* This type defines the structure of the `meta` property in TanStack Table column definitions.
*/
export type ColumnMeta = {
/** Required label for the column (used by ViewOptions and as fallback for header) */
label: string;
/** Optional custom React node to render in the header instead of the label */
headerContent?: React.ReactNode;
/** Optional alignment for the column content */
align?: 'left' | 'center' | 'right';
/** Optional CSS class name for the header cell */
headerClassName?: string;
/** Optional CSS class name for the body cells in this column */
cellClassName?: string;
/** Optional minimum width for the column (can be a number or CSS string) */
minWidth?: number | string;
/**
* Drop priority for responsive column visibility. Setting this opts the
* column in to responsive dropping; columns without a priority are never
* auto-hidden. Among opted-in columns, higher values are kept visible
* longer when container width shrinks; lower values are dropped first.
*/
priority?: number;
};
@@ -0,0 +1,348 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import type { Table as TanStackTable, VisibilityState } from '@tanstack/react-table';
import type { ColumnMeta } from './types';
// Default extra pixels required before re-showing a previously-hidden column on
// grow. Prevents oscillation at the threshold.
const DEFAULT_REGROW_SLACK_PX = 12;
type ColumnSizing = {
id: string;
minWidth: number;
priority: number;
alwaysVisible: boolean;
};
type ResponsiveColumnsContextValue = {
unfitColumnIds: Set<string>;
setUnfitColumnIds: React.Dispatch<React.SetStateAction<Set<string>>>;
};
const ResponsiveColumnsContext = createContext<ResponsiveColumnsContextValue | null>(null);
function useResponsiveColumnsContext(): ResponsiveColumnsContextValue {
const ctx = useContext(ResponsiveColumnsContext);
if (!ctx) {
throw new Error(
'useResponsiveColumns/useUnfitColumnIds must be used within a <ResponsiveColumnsProvider>'
);
}
return ctx;
}
/**
* Holds the responsive-layout state for a single DataTable instance.
* Rendered by `DataTableRoot` so the writer (`useResponsiveColumns`) and
* reader (`useUnfitColumnIds`) can communicate.
*/
export function ResponsiveColumnsProvider({ children }: { children: ReactNode }) {
const [unfitColumnIds, setUnfitColumnIds] = useState<Set<string>>(() => new Set());
const value = useMemo(() => ({ unfitColumnIds, setUnfitColumnIds }), [unfitColumnIds]);
return (
<ResponsiveColumnsContext.Provider value={value}>{children}</ResponsiveColumnsContext.Provider>
);
}
/**
* Returns the set of column ids the responsive layout has determined don't
* fit at the current table width. Consumed by `ViewOptions` to disable
* toggles whose effect would be immediately reverted.
*/
export function useUnfitColumnIds(): Set<string> {
return useResponsiveColumnsContext().unfitColumnIds;
}
type ComputeColumnVisibilityResult = {
/** Column ids hidden in the applied output (algorithm-dropped or user-hidden). */
hiddenIds: Set<string>;
/**
* Column ids whose View-menu toggle would have no visible effect right
* now — i.e. the algorithm would immediately re-hide them. A user-hidden
* column is unfit if lifting just *its* override wouldn't let it fit at
* its position in the walk (other user-hidden columns remain hidden). A
* non-user-hidden column is unfit if the algorithm has dropped it.
*/
unfitIds: Set<string>;
};
/**
* Walk droppable columns in priority desc (tiebreaking by minWidth asc) and
* compute both the applied visibility (after strict-priority drops and
* user-hidden overrides) and the "unfit" set used by the View menu.
*
* Strict-priority drop rule: the first column whose minWidth (plus regrow
* slack, if it was hidden on the previous run) doesn't fit in the remaining
* budget is dropped — and so is every lower-priority column after it, even
* if some of them would have fit on their own.
*
* `isUserHidden` items are hidden in the applied output without consuming
* budget or starting the drop, so hiding a mid-priority column via the View
* menu doesn't force every lower-priority column down with it.
*
* `wasHidden` reports each id's hidden state on the previous run; this
* controls regrow hysteresis.
*/
function computeColumnVisibility(
droppableColumns: ColumnSizing[],
budget: number,
isUserHidden: (id: string) => boolean,
wasHidden: (id: string) => boolean
): ComputeColumnVisibilityResult {
const hiddenIds = new Set<string>();
const unfitIds = new Set<string>();
let cumulative = 0;
let cascading = false;
for (const sizing of droppableColumns) {
const regrowSlack = wasHidden(sizing.id) ? DEFAULT_REGROW_SLACK_PX : 0;
// Would this column fit at this exact position in the walk? Used to
// answer "could the user re-show it?" for user-hidden columns and to
// decide when to start dropping the rest.
const fits = !cascading && cumulative + sizing.minWidth + regrowSlack <= budget;
if (isUserHidden(sizing.id)) {
hiddenIds.add(sizing.id);
if (!fits) {
unfitIds.add(sizing.id);
}
continue;
}
if (!fits) {
// Either dropping already started, or this column starts it.
hiddenIds.add(sizing.id);
unfitIds.add(sizing.id);
cascading = true;
continue;
}
// Fits — visible, consumes budget.
cumulative += sizing.minWidth;
}
return { hiddenIds, unfitIds };
}
function idSetsEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) {
return false;
}
for (const id of a) {
if (!b.has(id)) {
return false;
}
}
return true;
}
/**
* Drives responsive column visibility from a wrapper element's width.
*
* Setting `meta.priority` opts a column in to responsive dropping; columns
* without a priority (or marked `enableHiding: false`) are always visible
* and consume their `meta.minWidth` from the budget unconditionally. Among
* the opted-in columns, higher priority is dropped last.
*
* Strict-priority drop rule: walk droppable columns by priority desc,
* including each that fits the remaining budget. The first column that
* doesn't fit — and every lower-priority column after it — is dropped, even
* if some would have fit individually. This guarantees that on shrink
* columns can only disappear (never reappear) and on grow they return in
* the reverse of the order they were dropped.
*
* User overrides via the View menu are tracked separately: a column the
* user hides is added to a userHidden set and stays hidden through subsequent
* width changes. Re-showing a column via the View menu clears its entry; the
* algorithm may still drop it for space on the next shrink.
*
* Publishes the "unfit" set — hidden column ids whose View-menu toggle would
* have no visible effect because the algorithm would immediately re-hide
* them — through `ResponsiveColumnsProvider`. The check is per-column: a
* user-hidden column is unfit only if lifting *its* override (with other
* user overrides intact) wouldn't free enough budget for it to fit.
*
* This hook must be used inside a `<ResponsiveColumnsProvider>`.
*/
export function useResponsiveColumns<TData>(
table: TanStackTable<TData>,
wrapperRef: React.RefObject<HTMLElement | null>
): void {
const { setUnfitColumnIds } = useResponsiveColumnsContext();
// Snapshot of the visibility map the algorithm last applied. Used to detect
// user-driven toggles by diffing against the table's current state.
const lastColumnVisibilityRef = useRef<VisibilityState>({});
// Column ids the user has explicitly hidden via the View menu.
const userHiddenRef = useRef<Set<string>>(new Set());
// The last "unfit" set we published. Used to diff against the next run so
// we only call setUnfitColumnIds when the set actually changes.
const lastUnfitColumnIdsRef = useRef<Set<string>>(new Set());
// Snapshot column sizing data once per columns change. TanStack memoizes
// getAllLeafColumns() internally, so the array reference is stable across
// renders until the column definitions themselves change.
const leafColumns = table.getAllLeafColumns();
const columnSizings = useMemo<ColumnSizing[]>(() => {
return leafColumns.map(col => {
const meta = (col.columnDef.meta as ColumnMeta | undefined) ?? undefined;
const minWidth = typeof meta?.minWidth === 'number' ? meta.minWidth : 0;
const enableHiding = col.columnDef.enableHiding !== false;
const hasPriority = typeof meta?.priority === 'number';
return {
id: col.id,
minWidth,
priority: hasPriority ? (meta!.priority as number) : 0,
// Setting meta.priority opts a column in to responsive hiding;
// enableHiding: false opts it out. Columns without a priority are
// treated as always-visible and consume their minWidth from the budget.
alwaysVisible: !enableHiding || !hasPriority,
};
});
}, [leafColumns]);
// Droppable columns sorted in strict priority order (highest first), with
// minWidth ascending as a tiebreaker. computeColumnVisibility walks this list.
const droppableColumns = useMemo<ColumnSizing[]>(
() =>
columnSizings
.filter(s => !s.alwaysVisible)
.sort((a, b) => b.priority - a.priority || a.minWidth - b.minWidth),
[columnSizings]
);
// Guards the first algorithm invocation so we don't mistake the table's
// initial visibility for a user override.
const isFirstRunRef = useRef(true);
const runAlgorithm = useCallback(
(containerWidth: number) => {
if (droppableColumns.length === 0) {
return;
}
if (isFirstRunRef.current) {
// Seed the "last applied" snapshot with the table's current state so
// any initialVisibility from the consumer is treated as the algorithm's
// baseline, not as a user override.
lastColumnVisibilityRef.current = { ...table.getState().columnVisibility };
isFirstRunRef.current = false;
} else {
// Diff the table's current visibility against what we last applied;
// any divergence is a user toggle (via DataTable.ViewOptions).
const current = table.getState().columnVisibility;
const last = lastColumnVisibilityRef.current;
for (const sizing of droppableColumns) {
// Default visibility when a key is absent from VisibilityState is true.
const currentVisible = current[sizing.id] !== false;
const lastVisible = last[sizing.id] !== false;
if (currentVisible === lastVisible) {
continue;
}
if (currentVisible) {
// User re-showed a column: clear stickiness so the algorithm
// can manage it again. May still be dropped on the next shrink.
userHiddenRef.current.delete(sizing.id);
} else {
// User hid a column: remember so we don't auto-restore on grow.
userHiddenRef.current.add(sizing.id);
}
}
}
// Always-visible columns consume budget unconditionally; the walk
// operates on what remains.
let budget = containerWidth;
for (const sizing of columnSizings) {
if (sizing.alwaysVisible) {
budget -= sizing.minWidth;
}
}
// Single walk produces both the applied visibility (honoring user-
// hidden columns) and the unfit set (columns whose View-menu toggle
// would have no effect because the algorithm would immediately re-hide
// them).
const lastVisibility = lastColumnVisibilityRef.current;
const { hiddenIds: appliedHidden, unfitIds: nextUnfit } = computeColumnVisibility(
droppableColumns,
budget,
(id: string) => userHiddenRef.current.has(id),
(id: string) => lastVisibility[id] === false
);
// Build and apply the visibility map.
const nextVisibility: VisibilityState = {};
for (const sizing of droppableColumns) {
nextVisibility[sizing.id] = !appliedHidden.has(sizing.id);
}
const currentVisibility = table.getState().columnVisibility;
let appliedChanged = false;
for (const key of Object.keys(nextVisibility)) {
if ((currentVisibility[key] !== false) !== (nextVisibility[key] !== false)) {
appliedChanged = true;
break;
}
}
lastColumnVisibilityRef.current = nextVisibility;
if (appliedChanged) {
table.setColumnVisibility(prev => ({ ...prev, ...nextVisibility }));
}
// Publish the unfit set if it changed.
const lastUnfit = lastUnfitColumnIdsRef.current;
lastUnfitColumnIdsRef.current = nextUnfit;
if (!idSetsEqual(lastUnfit, nextUnfit)) {
setUnfitColumnIds(nextUnfit);
}
},
[table, columnSizings, droppableColumns, setUnfitColumnIds]
);
// Track and react to the table's width.
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || droppableColumns.length === 0) {
return;
}
runAlgorithm(wrapper.clientWidth);
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
runAlgorithm(entry.contentRect.width);
}
});
observer.observe(wrapper);
return () => observer.disconnect();
}, [runAlgorithm, wrapperRef, droppableColumns]);
// Re-run when columnVisibility changes from outside (e.g. a user toggle via
// the View menu). Without this, a user-hide that frees budget for a lower-
// priority column wouldn't take effect until the next resize. The diffs
// inside runAlgorithm make this a no-op when the change was algorithm-
// driven, so there's no feedback loop.
//
// Skipped on the initial mount: the width-driven effect above has already
// run the algorithm by this point, but table.getState() still reflects the
// pre-commit state (TanStack updates only when the parent re-renders),
// which would make the diff misinterpret the algorithm's own output as a
// user override. We only want this effect to react to subsequent changes.
const hasMountedRef = useRef(false);
const columnVisibility = table.getState().columnVisibility;
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
}
const wrapper = wrapperRef.current;
if (!wrapper || droppableColumns.length === 0) {
return;
}
runAlgorithm(wrapper.clientWidth);
}, [columnVisibility, runAlgorithm, wrapperRef, droppableColumns]);
}
@@ -83,32 +83,36 @@ export function DatePickerWithRange({
<Popover.Popover>
<Popover.PopoverTrigger asChild>
<div className="relative w-full">
<CalendarIcon className="text-foreground absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform" />
{!start && (
<CalendarIcon className="text-primary absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform" />
)}
<input
id={`${id}-start`}
type="text"
placeholder={t('Start Date', 'Start date')}
placeholder={t('Start', 'Start')}
autoComplete="off"
value={start}
onChange={e => handleInputChange(e, 'start')}
className={cn(
'border-inputfield-main focus:border-inputfield-focus hover:text-foreground h-[32px] w-full justify-start rounded border bg-background py-[6.5px] pl-[6.5px] pr-[6.5px] text-left text-base font-normal hover:bg-background',
!start && 'text-muted-foreground'
'border-inputfield-main focus:border-inputfield-focus hover:text-foreground placeholder:text-muted-foreground h-7 w-full justify-start rounded border bg-background pl-1.5 pr-0.5 py-1 text-left text-base font-normal hover:bg-background'
)}
data-cy="input-date-range-start"
/>
</div>
</Popover.PopoverTrigger>
<Popover.PopoverContent
className="w-auto p-0"
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
initialFocus
autoFocus
mode="single"
captionLayout="dropdown"
defaultMonth={start ? parse(start, 'yyyy-MM-dd', new Date()) : new Date()}
selected={start ? parse(start, 'yyyy-MM-dd', new Date()) : undefined}
onSelect={handleStartSelect}
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 1, 11)}
numberOfMonths={1}
/>
</Popover.PopoverContent>
@@ -120,32 +124,36 @@ export function DatePickerWithRange({
>
<Popover.PopoverTrigger asChild>
<div className="relative w-full">
<CalendarIcon className="text-foreground absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform" />
{!end && (
<CalendarIcon className="text-primary absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 transform" />
)}
<input
id={`${id}-end`}
type="text"
placeholder={t('End Date', 'End date')}
placeholder={t('End', 'End')}
autoComplete="off"
value={end}
onChange={e => handleInputChange(e, 'end')}
className={cn(
'border-inputfield-main focus:border-inputfield-focus hover:text-foreground h-full w-full justify-start rounded border bg-background py-[6.5px] pl-[6.5px] pr-[6.5px] text-left text-base font-normal hover:bg-background',
!end && 'text-muted-foreground'
'border-inputfield-main focus:border-inputfield-focus hover:text-foreground placeholder:text-muted-foreground h-7 w-full justify-start rounded border bg-background pl-1.5 pr-0.5 py-1 text-left text-base font-normal hover:bg-background'
)}
data-cy="input-date-range-end"
/>
</div>
</Popover.PopoverTrigger>
<Popover.PopoverContent
className="w-auto p-0"
className="w-auto overflow-hidden p-0"
align="start"
>
<Calendar
initialFocus
autoFocus
mode="single"
captionLayout="dropdown"
defaultMonth={start ? parse(start, 'yyyy-MM-dd', new Date()) : new Date()}
selected={end ? parse(end, 'yyyy-MM-dd', new Date()) : undefined}
onSelect={handleEndSelect}
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 1, 11)}
numberOfMonths={1}
/>
</Popover.PopoverContent>
@@ -34,7 +34,9 @@ import More from './Sources/More';
import MultiplePatients from './Sources/MultiplePatients';
import NavigationPanelReveal from './Sources/NavigationPanelReveal';
import OHIFLogo from './Sources/OHIFLogo';
import OHIFLogoHorizontal from './Sources/OHIFLogoHorizontal';
import Patient from './Sources/Patient';
import PatientStudyList from './Sources/PatientStudyList';
import Pin from './Sources/Pin';
import PinFill from './Sources/PinFill';
import Plus from './Sources/Plus';
@@ -43,6 +45,7 @@ import Redo from './Sources/Redo';
import Refresh from './Sources/Refresh';
import Rename from './Sources/Rename';
import Series from './Sources/Series';
import SeriesPlaceholder from './Sources/SeriesPlaceholder';
import Settings from './Sources/Settings';
import Show from './Sources/Show';
import SidePanelCloseLeft from './Sources/SidePanelCloseLeft';
@@ -50,6 +53,9 @@ import SidePanelCloseRight from './Sources/SidePanelCloseRight';
import SortingAscending from './Sources/SortingAscending';
import SocialGithub from './Sources/SocialGithub';
import SortingDescending from './Sources/SortingDescending';
import SortingNew from './Sources/SortingNew';
import SortingNewAscending from './Sources/SortingNewAscending';
import SortingNewDescending from './Sources/SortingNewDescending';
import StatusError from './Sources/StatusError';
import StatusSuccess from './Sources/StatusSuccess';
import StatusTracking from './Sources/StatusTracking';
@@ -157,9 +163,11 @@ import ContentPrev from './Sources/ContentPrev';
import ContentNext from './Sources/ContentNext';
import CheckBoxChecked from './Sources/CheckBoxChecked';
import CheckBoxUnchecked from './Sources/CheckBoxUnChecked';
import CloudSettings from './Sources/CloudSettings';
import Close from './Sources/Close';
import Pause from './Sources/Pause';
import Play from './Sources/Play';
import PanelRight from './Sources/PanelRight';
import ViewportWindowLevel from './Sources/ViewportWindowLevel';
import Search from './Sources/Search';
import Clear from './Sources/Clear';
@@ -233,6 +241,7 @@ import StatusAlert from './Sources/StatusAlert';
import Undo from './Sources/Undo';
import TabContours from './Sources/TabContours';
import IllustrationNotFound from './Sources/IllustrationNotFound';
import SettingsStudyList from './Sources/SettingsStudyList';
//
//
type IconProps = React.HTMLAttributes<SVGElement>;
@@ -422,6 +431,7 @@ export const Icons = {
OrientationSwitchR,
Checked,
Clipboard,
CloudSettings,
ActionNewDialog,
GroupLayers,
Database,
@@ -508,7 +518,9 @@ export const Icons = {
MultiplePatients,
NavigationPanelReveal,
OHIFLogo,
OHIFLogoHorizontal,
Patient,
PatientStudyList,
Pin,
PinFill,
Plus,
@@ -516,14 +528,20 @@ export const Icons = {
Refresh,
Rename,
Series,
SeriesPlaceholder,
Settings,
SettingsStudyList,
Show,
SidePanelCloseLeft,
SidePanelCloseRight,
PanelRight,
SocialGithub,
SortingAscending,
SortingDescending,
Sorting,
SortingNew,
SortingNewAscending,
SortingNewDescending,
StatusError,
StatusSuccess,
StatusTracking,
@@ -650,6 +668,7 @@ export const Icons = {
magnifier: (props: IconProps) => Magnifier(props),
'status-alert-warning': (props: IconProps) => StatusWarning(props),
'logo-dark-background': (props: IconProps) => OHIFLogoColorDarkBackground(props),
'ohif-logo-horizontal': (props: IconProps) => OHIFLogoHorizontal(props),
'external-link': (props: IconProps) => ExternalLink(props),
'checkbox-checked': (props: IconProps) => CheckBoxChecked(props),
'checkbox-unchecked': (props: IconProps) => CheckBoxUnchecked(props),
@@ -692,6 +711,7 @@ export const Icons = {
'power-off': (props: IconProps) => PowerOff(props),
'icon-multiple-patients': (props: IconProps) => MultiplePatients(props),
'icon-patient': (props: IconProps) => Patient(props),
'panel-right': (props: IconProps) => PanelRight(props),
'chevron-down': (props: IconProps) => ChevronOpen(props),
'tool-length': (props: IconProps) => ToolLength(props),
'tool-3d-rotate': (props: IconProps) => Tool3DRotate(props),
@@ -760,6 +780,8 @@ export const Icons = {
'content-prev': (props: IconProps) => ContentPrev(props),
'content-next': (props: IconProps) => ContentNext(props),
'icon-settings': (props: IconProps) => Settings(props),
'settings-study-list': (props: IconProps) => SettingsStudyList(props),
'cloud-settings': (props: IconProps) => CloudSettings(props),
close: (props: IconProps) => Close(props),
pause: (props: IconProps) => Pause(props),
'icon-pause': (props: IconProps) => Pause(props),
@@ -0,0 +1,34 @@
import React from 'react';
import type { IconProps } from '../types';
export const CloudSettings = (props: IconProps) => (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M14.125 12.625H15.25C15.25 12.625 18.625 12.1855 18.625 8.875C18.6254 8.37127 18.5241 7.87265 18.3271 7.40901C18.1302 6.94536 17.8417 6.52623 17.479 6.17672C17.1163 5.82721 16.6867 5.55451 16.2161 5.37495C15.7454 5.19539 15.2434 5.11267 14.74 5.13175C14.2436 4.07779 13.4101 3.21927 12.3713 2.69184C11.3325 2.16441 10.1476 1.99814 9.00376 2.21928C7.85992 2.44043 6.82237 3.03639 6.05502 3.91302C5.28768 4.78964 4.8343 5.89695 4.7665 7.06C4.35698 6.97399 3.93343 6.98042 3.52671 7.07882C3.11999 7.17722 2.74035 7.36511 2.41544 7.6288C2.09052 7.8925 1.82853 8.22535 1.64854 8.60312C1.46855 8.98089 1.3751 9.39405 1.375 9.8125C1.3418 10.468 1.55602 11.1121 1.97524 11.6172C2.39445 12.1222 2.98807 12.4514 3.6385 12.5395"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.83074 14.2699C9.3602 14.2699 9.78941 13.8407 9.78941 13.3112C9.78941 12.7818 9.3602 12.3526 8.83074 12.3526C8.30128 12.3526 7.87207 12.7818 7.87207 13.3112C7.87207 13.8407 8.30128 14.2699 8.83074 14.2699Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9.64474 9.60392L9.92741 10.5319C9.97374 10.6862 10.077 10.817 10.2163 10.8979C10.3556 10.9787 10.5205 11.0035 10.6774 10.9673L11.6181 10.7493C11.7964 10.7091 11.9828 10.727 12.1501 10.8005C12.3175 10.874 12.4568 10.9992 12.5479 11.1577C12.6389 11.3161 12.6767 11.4996 12.6559 11.6812C12.6351 11.8627 12.5566 12.0328 12.4321 12.1666L11.7734 12.8759C11.6647 12.9941 11.6044 13.1487 11.6044 13.3093C11.6044 13.4698 11.6647 13.6244 11.7734 13.7426L12.4314 14.4526C12.556 14.5863 12.6344 14.7564 12.6552 14.938C12.6761 15.1196 12.6382 15.303 12.5472 15.4615C12.4562 15.62 12.3168 15.7451 12.1495 15.8186C11.9822 15.8921 11.7957 15.9101 11.6174 15.8699L10.6767 15.6519C10.52 15.6169 10.3557 15.6427 10.2173 15.7242C10.0788 15.8056 9.97638 15.9366 9.93074 16.0906L9.64807 17.0186C9.59546 17.1936 9.48787 17.3469 9.34125 17.4559C9.19463 17.565 9.01678 17.6238 8.83407 17.6238C8.65136 17.6238 8.47352 17.565 8.3269 17.4559C8.18028 17.3469 8.07269 17.1936 8.02007 17.0186L7.73407 16.0906C7.68774 15.9363 7.58444 15.8055 7.44514 15.7246C7.30585 15.6438 7.141 15.619 6.98407 15.6553L6.04341 15.8733C5.86513 15.9134 5.67866 15.8955 5.51134 15.822C5.34402 15.7485 5.20464 15.6233 5.11362 15.4648C5.02261 15.3064 4.98474 15.1229 5.00558 14.9413C5.02642 14.7598 5.10486 14.5897 5.22941 14.4559L5.88741 13.7459C5.9961 13.6278 6.05643 13.4731 6.05643 13.3126C6.05643 13.1521 5.9961 12.9974 5.88741 12.8793L5.22941 12.1693C5.10486 12.0355 5.02642 11.8654 5.00558 11.6838C4.98474 11.5023 5.02261 11.3188 5.11362 11.1603C5.20464 11.0019 5.34402 10.8767 5.51134 10.8032C5.67866 10.7297 5.86513 10.7117 6.04341 10.7519L6.98407 10.9699C7.14099 11.006 7.30576 10.9812 7.44502 10.9003C7.58428 10.8195 7.6876 10.6887 7.73407 10.5346L8.01674 9.60658C8.06906 9.43153 8.1764 9.27799 8.32285 9.16873C8.46929 9.05947 8.64704 9.0003 8.82975 9C9.01246 8.9997 9.1904 9.05828 9.3372 9.16706C9.484 9.27584 9.59184 9.42903 9.64474 9.60392Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default CloudSettings;
@@ -10,7 +10,6 @@ export const More = (props: IconProps) => (
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>icon-more</title>
<g
id="icon-more"
stroke="none"
@@ -0,0 +1,139 @@
import React from 'react';
import type { IconProps } from '../types';
export const OHIFLogoHorizontal = (props: IconProps) => (
<svg
width="233"
height="23"
viewBox="0 0 233 23"
fill="none"
{...props}
>
<path
d="M227.792 11.7344V16H226.702V8.90909H227.755V10.017H227.847C228.013 9.65696 228.266 9.36766 228.604 9.14915C228.943 8.92756 229.38 8.81676 229.915 8.81676C230.395 8.81676 230.816 8.91525 231.176 9.11222C231.536 9.30611 231.816 9.60156 232.016 9.99858C232.216 10.3925 232.316 10.8911 232.316 11.4943V16H231.226V11.5682C231.226 11.0111 231.082 10.5772 230.792 10.2663C230.503 9.95241 230.106 9.79545 229.601 9.79545C229.254 9.79545 228.943 9.87086 228.669 10.0217C228.398 10.1725 228.184 10.3925 228.027 10.6818C227.87 10.9711 227.792 11.322 227.792 11.7344Z"
fill="white"
/>
<path
d="M221.826 16.1477C221.186 16.1477 220.624 15.9954 220.141 15.6907C219.661 15.386 219.285 14.9598 219.015 14.4119C218.747 13.8641 218.613 13.224 218.613 12.4915C218.613 11.7528 218.747 11.1081 219.015 10.5572C219.285 10.0063 219.661 9.57848 220.141 9.27379C220.624 8.9691 221.186 8.81676 221.826 8.81676C222.466 8.81676 223.026 8.9691 223.507 9.27379C223.99 9.57848 224.365 10.0063 224.633 10.5572C224.904 11.1081 225.039 11.7528 225.039 12.4915C225.039 13.224 224.904 13.8641 224.633 14.4119C224.365 14.9598 223.99 15.386 223.507 15.6907C223.026 15.9954 222.466 16.1477 221.826 16.1477ZM221.826 15.169C222.312 15.169 222.712 15.0444 223.026 14.7951C223.34 14.5458 223.573 14.218 223.723 13.8118C223.874 13.4055 223.95 12.9654 223.95 12.4915C223.95 12.0175 223.874 11.5759 223.723 11.1665C223.573 10.7572 223.34 10.4264 223.026 10.174C222.712 9.92164 222.312 9.79545 221.826 9.79545C221.34 9.79545 220.94 9.92164 220.626 10.174C220.312 10.4264 220.08 10.7572 219.929 11.1665C219.778 11.5759 219.703 12.0175 219.703 12.4915C219.703 12.9654 219.778 13.4055 219.929 13.8118C220.08 14.218 220.312 14.5458 220.626 14.7951C220.94 15.0444 221.34 15.169 221.826 15.169Z"
fill="white"
/>
<path
d="M215.861 16V8.90909H216.95V16H215.861ZM216.414 7.72727C216.202 7.72727 216.019 7.65495 215.865 7.5103C215.714 7.36565 215.639 7.19176 215.639 6.98864C215.639 6.78551 215.714 6.61162 215.865 6.46697C216.019 6.32232 216.202 6.25 216.414 6.25C216.627 6.25 216.808 6.32232 216.959 6.46697C217.113 6.61162 217.19 6.78551 217.19 6.98864C217.19 7.19176 217.113 7.36565 216.959 7.5103C216.808 7.65495 216.627 7.72727 216.414 7.72727Z"
fill="white"
/>
<path
d="M214.222 8.90909V9.83239H210.547V8.90909H214.222ZM211.618 7.21023H212.707V13.9688C212.707 14.2765 212.752 14.5073 212.841 14.6612C212.934 14.812 213.051 14.9136 213.192 14.9659C213.337 15.0152 213.489 15.0398 213.649 15.0398C213.769 15.0398 213.868 15.0336 213.945 15.0213C214.022 15.0059 214.083 14.9936 214.129 14.9844L214.351 15.9631C214.277 15.9908 214.174 16.0185 214.042 16.0462C213.909 16.0769 213.741 16.0923 213.538 16.0923C213.231 16.0923 212.929 16.0262 212.634 15.8938C212.341 15.7615 212.098 15.5599 211.904 15.2891C211.713 15.0182 211.618 14.6766 211.618 14.2642V7.21023Z"
fill="white"
/>
<path
d="M205.899 16.1662C205.45 16.1662 205.042 16.0816 204.676 15.9123C204.31 15.7399 204.019 15.4922 203.803 15.169C203.588 14.8428 203.48 14.4489 203.48 13.9872C203.48 13.581 203.56 13.2517 203.72 12.9993C203.88 12.7438 204.094 12.5438 204.362 12.3991C204.63 12.2545 204.925 12.1468 205.248 12.076C205.575 12.0021 205.902 11.9437 206.232 11.9006C206.663 11.8452 207.012 11.8036 207.28 11.7759C207.55 11.7451 207.747 11.6944 207.87 11.6236C207.997 11.5528 208.06 11.4297 208.06 11.2543V11.2173C208.06 10.7618 207.935 10.4079 207.686 10.1555C207.44 9.90317 207.066 9.77699 206.564 9.77699C206.044 9.77699 205.636 9.89086 205.341 10.1186C205.045 10.3464 204.837 10.5895 204.717 10.848L203.683 10.4787C203.868 10.0478 204.114 9.71236 204.422 9.4723C204.733 9.22917 205.071 9.0599 205.438 8.96449C205.807 8.866 206.17 8.81676 206.527 8.81676C206.755 8.81676 207.016 8.84446 207.312 8.89986C207.61 8.95218 207.898 9.06143 208.175 9.22763C208.455 9.39382 208.688 9.64465 208.872 9.98011C209.057 10.3156 209.149 10.7649 209.149 11.3281V16H208.06V15.0398H208.004C207.931 15.1937 207.807 15.3583 207.635 15.5337C207.463 15.7092 207.233 15.8584 206.947 15.9815C206.661 16.1046 206.312 16.1662 205.899 16.1662ZM206.065 15.1875C206.496 15.1875 206.859 15.1029 207.155 14.9336C207.453 14.7643 207.678 14.5458 207.829 14.2781C207.983 14.0103 208.06 13.7287 208.06 13.4332V12.4361C208.014 12.4915 207.912 12.5423 207.755 12.5884C207.601 12.6315 207.423 12.67 207.22 12.7038C207.02 12.7346 206.824 12.7623 206.633 12.7869C206.446 12.8085 206.293 12.8269 206.176 12.8423C205.893 12.8793 205.628 12.9393 205.382 13.0224C205.139 13.1024 204.942 13.224 204.791 13.3871C204.644 13.5471 204.57 13.7656 204.57 14.0426C204.57 14.4212 204.71 14.7074 204.99 14.9013C205.273 15.0921 205.631 15.1875 206.065 15.1875Z"
fill="white"
/>
<path
d="M198.416 16.1477C197.825 16.1477 197.303 15.9985 196.851 15.6999C196.399 15.3983 196.045 14.9736 195.789 14.4258C195.534 13.8749 195.406 13.224 195.406 12.473C195.406 11.7282 195.534 11.0819 195.789 10.5341C196.045 9.98627 196.4 9.56309 196.856 9.26456C197.311 8.96603 197.837 8.81676 198.434 8.81676C198.896 8.81676 199.261 8.8937 199.529 9.04759C199.799 9.19839 200.006 9.37074 200.147 9.56463C200.292 9.75544 200.404 9.9124 200.484 10.0355H200.576V6.54545H201.666V16H200.613V14.9105H200.484C200.404 15.0398 200.29 15.2029 200.143 15.3999C199.995 15.5937 199.784 15.7676 199.51 15.9215C199.236 16.0723 198.871 16.1477 198.416 16.1477ZM198.564 15.169C199.001 15.169 199.37 15.0552 199.672 14.8274C199.973 14.5966 200.203 14.2781 200.359 13.8718C200.516 13.4625 200.595 12.9901 200.595 12.4545C200.595 11.9252 200.518 11.462 200.364 11.065C200.21 10.6649 199.982 10.354 199.681 10.1325C199.379 9.90779 199.007 9.79545 198.564 9.79545C198.102 9.79545 197.717 9.91394 197.41 10.1509C197.105 10.3848 196.876 10.7034 196.722 11.1065C196.571 11.5066 196.495 11.956 196.495 12.4545C196.495 12.9593 196.572 13.4179 196.726 13.8303C196.883 14.2396 197.114 14.5658 197.419 14.8089C197.727 15.049 198.108 15.169 198.564 15.169Z"
fill="white"
/>
<path
d="M189.223 11.7344V16H188.134V8.90909H189.186V10.017H189.279C189.445 9.65696 189.697 9.36766 190.036 9.14915C190.374 8.92756 190.811 8.81676 191.347 8.81676C191.827 8.81676 192.247 8.91525 192.607 9.11222C192.967 9.30611 193.247 9.60156 193.447 9.99858C193.648 10.3925 193.748 10.8911 193.748 11.4943V16H192.658V11.5682C192.658 11.0111 192.513 10.5772 192.224 10.2663C191.935 9.95241 191.538 9.79545 191.033 9.79545C190.685 9.79545 190.374 9.87086 190.101 10.0217C189.83 10.1725 189.616 10.3925 189.459 10.6818C189.302 10.9711 189.223 11.322 189.223 11.7344Z"
fill="white"
/>
<path
d="M185.049 13.1009V8.90909H186.138V16H185.049V14.7997H184.975C184.809 15.1598 184.55 15.466 184.2 15.7184C183.849 15.9677 183.405 16.0923 182.87 16.0923C182.427 16.0923 182.033 15.9954 181.688 15.8015C181.343 15.6045 181.073 15.3091 180.876 14.9151C180.679 14.5181 180.58 14.018 180.58 13.4148V8.90909H181.67V13.3409C181.67 13.858 181.814 14.2704 182.104 14.5781C182.396 14.8859 182.768 15.0398 183.221 15.0398C183.492 15.0398 183.767 14.9705 184.047 14.832C184.33 14.6935 184.567 14.4812 184.758 14.195C184.952 13.9087 185.049 13.544 185.049 13.1009Z"
fill="white"
/>
<path
d="M175.704 16.1477C175.064 16.1477 174.502 15.9954 174.019 15.6907C173.539 15.386 173.163 14.9598 172.893 14.4119C172.625 13.8641 172.491 13.224 172.491 12.4915C172.491 11.7528 172.625 11.1081 172.893 10.5572C173.163 10.0063 173.539 9.57848 174.019 9.27379C174.502 8.9691 175.064 8.81676 175.704 8.81676C176.344 8.81676 176.904 8.9691 177.384 9.27379C177.868 9.57848 178.243 10.0063 178.511 10.5572C178.782 11.1081 178.917 11.7528 178.917 12.4915C178.917 13.224 178.782 13.8641 178.511 14.4119C178.243 14.9598 177.868 15.386 177.384 15.6907C176.904 15.9954 176.344 16.1477 175.704 16.1477ZM175.704 15.169C176.19 15.169 176.59 15.0444 176.904 14.7951C177.218 14.5458 177.451 14.218 177.601 13.8118C177.752 13.4055 177.828 12.9654 177.828 12.4915C177.828 12.0175 177.752 11.5759 177.601 11.1665C177.451 10.7572 177.218 10.4264 176.904 10.174C176.59 9.92164 176.19 9.79545 175.704 9.79545C175.218 9.79545 174.818 9.92164 174.504 10.174C174.19 10.4264 173.957 10.7572 173.807 11.1665C173.656 11.5759 173.58 12.0175 173.58 12.4915C173.58 12.9654 173.656 13.4055 173.807 13.8118C173.957 14.218 174.19 14.5458 174.504 14.7951C174.818 15.0444 175.218 15.169 175.704 15.169Z"
fill="white"
/>
<path
d="M165.862 16V6.54545H171.531V7.56108H167.007V10.7557H171.106V11.7713H167.007V16H165.862Z"
fill="white"
/>
<path
d="M156.998 18.8068C156.472 18.8068 156.019 18.7391 155.641 18.6037C155.262 18.4714 154.947 18.2959 154.694 18.0774C154.445 17.862 154.247 17.6312 154.099 17.3849L154.967 16.7756C155.065 16.9048 155.19 17.0526 155.341 17.2188C155.492 17.388 155.698 17.5342 155.959 17.6573C156.224 17.7835 156.57 17.8466 156.998 17.8466C157.571 17.8466 158.043 17.7081 158.415 17.4311C158.788 17.1541 158.974 16.7202 158.974 16.1293V14.6889H158.882C158.802 14.8182 158.688 14.9782 158.54 15.169C158.395 15.3568 158.186 15.5245 157.912 15.6722C157.641 15.8169 157.275 15.8892 156.813 15.8892C156.241 15.8892 155.727 15.7538 155.272 15.483C154.819 15.2121 154.461 14.8182 154.196 14.3011C153.934 13.7841 153.803 13.1562 153.803 12.4176C153.803 11.6913 153.931 11.0588 154.187 10.5202C154.442 9.97857 154.798 9.56001 155.253 9.26456C155.709 8.96603 156.235 8.81676 156.832 8.81676C157.294 8.81676 157.66 8.8937 157.931 9.04759C158.205 9.19839 158.414 9.37074 158.558 9.56463C158.706 9.75544 158.82 9.9124 158.9 10.0355H159.011V8.90909H160.063V16.2031C160.063 16.8125 159.925 17.308 159.648 17.6896C159.374 18.0743 159.005 18.3559 158.54 18.5344C158.078 18.716 157.564 18.8068 156.998 18.8068ZM156.961 14.9105C157.398 14.9105 157.767 14.8105 158.069 14.6104C158.371 14.4104 158.6 14.1226 158.757 13.7472C158.914 13.3717 158.992 12.9223 158.992 12.3991C158.992 11.8883 158.915 11.4374 158.762 11.0465C158.608 10.6557 158.38 10.3494 158.078 10.1278C157.777 9.90625 157.404 9.79545 156.961 9.79545C156.499 9.79545 156.115 9.9124 155.807 10.1463C155.502 10.3802 155.273 10.6941 155.119 11.0881C154.968 11.482 154.893 11.919 154.893 12.3991C154.893 12.8916 154.97 13.3271 155.124 13.7056C155.281 14.0811 155.512 14.3765 155.816 14.592C156.124 14.8043 156.506 14.9105 156.961 14.9105Z"
fill="white"
/>
<path
d="M147.621 11.7344V16H146.531V8.90909H147.584V10.017H147.676C147.842 9.65696 148.095 9.36766 148.433 9.14915C148.772 8.92756 149.209 8.81676 149.744 8.81676C150.225 8.81676 150.645 8.91525 151.005 9.11222C151.365 9.30611 151.645 9.60156 151.845 9.99858C152.045 10.3925 152.145 10.8911 152.145 11.4943V16H151.056V11.5682C151.056 11.0111 150.911 10.5772 150.622 10.2663C150.332 9.95241 149.935 9.79545 149.431 9.79545C149.083 9.79545 148.772 9.87086 148.498 10.0217C148.227 10.1725 148.013 10.3925 147.856 10.6818C147.699 10.9711 147.621 11.322 147.621 11.7344Z"
fill="white"
/>
<path
d="M143.446 16V8.90909H144.536V16H143.446ZM144 7.72727C143.788 7.72727 143.605 7.65495 143.451 7.5103C143.3 7.36565 143.225 7.19176 143.225 6.98864C143.225 6.78551 143.3 6.61162 143.451 6.46697C143.605 6.32232 143.788 6.25 144 6.25C144.213 6.25 144.394 6.32232 144.545 6.46697C144.699 6.61162 144.776 6.78551 144.776 6.98864C144.776 7.19176 144.699 7.36565 144.545 7.5103C144.394 7.65495 144.213 7.72727 144 7.72727Z"
fill="white"
/>
<path
d="M138.387 18.8068C137.86 18.8068 137.408 18.7391 137.029 18.6037C136.651 18.4714 136.335 18.2959 136.083 18.0774C135.834 17.862 135.635 17.6312 135.488 17.3849L136.355 16.7756C136.454 16.9048 136.579 17.0526 136.729 17.2188C136.88 17.388 137.086 17.5342 137.348 17.6573C137.613 17.7835 137.959 17.8466 138.387 17.8466C138.959 17.8466 139.432 17.7081 139.804 17.4311C140.176 17.1541 140.363 16.7202 140.363 16.1293V14.6889H140.27C140.19 14.8182 140.076 14.9782 139.929 15.169C139.784 15.3568 139.575 15.5245 139.301 15.6722C139.03 15.8169 138.664 15.8892 138.202 15.8892C137.63 15.8892 137.116 15.7538 136.66 15.483C136.208 15.2121 135.849 14.8182 135.585 14.3011C135.323 13.7841 135.192 13.1562 135.192 12.4176C135.192 11.6913 135.32 11.0588 135.575 10.5202C135.831 9.97857 136.186 9.56001 136.642 9.26456C137.097 8.96603 137.623 8.81676 138.221 8.81676C138.682 8.81676 139.048 8.8937 139.319 9.04759C139.593 9.19839 139.802 9.37074 139.947 9.56463C140.095 9.75544 140.209 9.9124 140.289 10.0355H140.4V8.90909H141.452V16.2031C141.452 16.8125 141.314 17.308 141.037 17.6896C140.763 18.0743 140.393 18.3559 139.929 18.5344C139.467 18.716 138.953 18.8068 138.387 18.8068ZM138.35 14.9105C138.787 14.9105 139.156 14.8105 139.458 14.6104C139.759 14.4104 139.989 14.1226 140.146 13.7472C140.303 13.3717 140.381 12.9223 140.381 12.3991C140.381 11.8883 140.304 11.4374 140.15 11.0465C139.996 10.6557 139.769 10.3494 139.467 10.1278C139.165 9.90625 138.793 9.79545 138.35 9.79545C137.888 9.79545 137.503 9.9124 137.196 10.1463C136.891 10.3802 136.662 10.6941 136.508 11.0881C136.357 11.482 136.282 11.919 136.282 12.3991C136.282 12.8916 136.359 13.3271 136.512 13.7056C136.669 14.0811 136.9 14.3765 137.205 14.592C137.513 14.8043 137.894 14.9105 138.35 14.9105Z"
fill="white"
/>
<path
d="M130.286 16.1662C129.837 16.1662 129.429 16.0816 129.063 15.9123C128.696 15.7399 128.406 15.4922 128.19 15.169C127.975 14.8428 127.867 14.4489 127.867 13.9872C127.867 13.581 127.947 13.2517 128.107 12.9993C128.267 12.7438 128.481 12.5438 128.749 12.3991C129.016 12.2545 129.312 12.1468 129.635 12.076C129.961 12.0021 130.289 11.9437 130.618 11.9006C131.049 11.8452 131.399 11.8036 131.666 11.7759C131.937 11.7451 132.134 11.6944 132.257 11.6236C132.383 11.5528 132.446 11.4297 132.446 11.2543V11.2173C132.446 10.7618 132.322 10.4079 132.073 10.1555C131.826 9.90317 131.452 9.77699 130.951 9.77699C130.431 9.77699 130.023 9.89086 129.727 10.1186C129.432 10.3464 129.224 10.5895 129.104 10.848L128.07 10.4787C128.255 10.0478 128.501 9.71236 128.809 9.4723C129.12 9.22917 129.458 9.0599 129.824 8.96449C130.194 8.866 130.557 8.81676 130.914 8.81676C131.142 8.81676 131.403 8.84446 131.699 8.89986C131.997 8.95218 132.285 9.06143 132.562 9.22763C132.842 9.39382 133.074 9.64465 133.259 9.98011C133.444 10.3156 133.536 10.7649 133.536 11.3281V16H132.446V15.0398H132.391C132.317 15.1937 132.194 15.3583 132.022 15.5337C131.849 15.7092 131.62 15.8584 131.334 15.9815C131.048 16.1046 130.698 16.1662 130.286 16.1662ZM130.452 15.1875C130.883 15.1875 131.246 15.1029 131.542 14.9336C131.84 14.7643 132.065 14.5458 132.216 14.2781C132.37 14.0103 132.446 13.7287 132.446 13.4332V12.4361C132.4 12.4915 132.299 12.5423 132.142 12.5884C131.988 12.6315 131.809 12.67 131.606 12.7038C131.406 12.7346 131.211 12.7623 131.02 12.7869C130.832 12.8085 130.68 12.8269 130.563 12.8423C130.28 12.8793 130.015 12.9393 129.769 13.0224C129.526 13.1024 129.329 13.224 129.178 13.3871C129.03 13.5471 128.956 13.7656 128.956 14.0426C128.956 14.4212 129.096 14.7074 129.377 14.9013C129.66 15.0921 130.018 15.1875 130.452 15.1875Z"
fill="white"
/>
<path
d="M116.901 16V8.90909H117.953V10.017H118.045C118.193 9.63849 118.432 9.34458 118.761 9.1353C119.09 8.92294 119.486 8.81676 119.947 8.81676C120.415 8.81676 120.805 8.92294 121.115 9.1353C121.429 9.34458 121.674 9.63849 121.849 10.017H121.923C122.105 9.6508 122.377 9.35997 122.74 9.14453C123.104 8.92602 123.539 8.81676 124.047 8.81676C124.681 8.81676 125.199 9.01527 125.603 9.41229C126.006 9.80623 126.207 10.4202 126.207 11.2543V16H125.118V11.2543C125.118 10.7311 124.975 10.3571 124.689 10.1325C124.402 9.90779 124.065 9.79545 123.677 9.79545C123.179 9.79545 122.793 9.94626 122.519 10.2479C122.245 10.5464 122.108 10.925 122.108 11.3835V16H121V11.1435C121 10.7403 120.869 10.4156 120.608 10.1694C120.346 9.9201 120.009 9.79545 119.597 9.79545C119.313 9.79545 119.049 9.87086 118.802 10.0217C118.559 10.1725 118.362 10.3817 118.212 10.6495C118.064 10.9142 117.99 11.2204 117.99 11.5682V16H116.901Z"
fill="white"
/>
<path
d="M114.753 6.54545V16H113.608V6.54545H114.753Z"
fill="white"
/>
<path
d="M103.213 11.7344V16H102.123V6.54545H103.213V10.017H103.305C103.471 9.6508 103.72 9.35997 104.053 9.14453C104.388 8.92602 104.835 8.81676 105.392 8.81676C105.875 8.81676 106.298 8.91371 106.661 9.1076C107.024 9.29841 107.306 9.59233 107.506 9.98935C107.709 10.3833 107.811 10.8849 107.811 11.4943V16H106.721V11.5682C106.721 11.005 106.575 10.5695 106.283 10.2617C105.993 9.95088 105.592 9.79545 105.078 9.79545C104.721 9.79545 104.401 9.87086 104.117 10.0217C103.837 10.1725 103.616 10.3925 103.453 10.6818C103.293 10.9711 103.213 11.322 103.213 11.7344Z"
fill="white"
/>
<path
d="M100.332 8.90909V9.83239H96.6572V8.90909H100.332ZM97.7282 7.21023H98.8177V13.9688C98.8177 14.2765 98.8624 14.5073 98.9516 14.6612C99.0439 14.812 99.1609 14.9136 99.3025 14.9659C99.4471 15.0152 99.5995 15.0398 99.7595 15.0398C99.8795 15.0398 99.978 15.0336 100.055 15.0213C100.132 15.0059 100.193 14.9936 100.24 14.9844L100.461 15.9631C100.387 15.9908 100.284 16.0185 100.152 16.0462C100.02 16.0769 99.8518 16.0923 99.6487 16.0923C99.3409 16.0923 99.0393 16.0262 98.7439 15.8938C98.4515 15.7615 98.2084 15.5599 98.0145 15.2891C97.8237 15.0182 97.7282 14.6766 97.7282 14.2642V7.21023Z"
fill="white"
/>
<path
d="M95.2527 6.54545V16H94.1632V6.54545H95.2527Z"
fill="white"
/>
<path
d="M88.9246 16.1662C88.4753 16.1662 88.0675 16.0816 87.7013 15.9123C87.335 15.7399 87.0442 15.4922 86.8288 15.169C86.6133 14.8428 86.5056 14.4489 86.5056 13.9872C86.5056 13.581 86.5856 13.2517 86.7457 12.9993C86.9057 12.7438 87.1196 12.5438 87.3874 12.3991C87.6551 12.2545 87.9506 12.1468 88.2737 12.076C88.6 12.0021 88.9277 11.9437 89.257 11.9006C89.6879 11.8452 90.0372 11.8036 90.305 11.7759C90.5758 11.7451 90.7728 11.6944 90.8959 11.6236C91.0221 11.5528 91.0852 11.4297 91.0852 11.2543V11.2173C91.0852 10.7618 90.9605 10.4079 90.7112 10.1555C90.465 9.90317 90.0911 9.77699 89.5894 9.77699C89.0693 9.77699 88.6615 9.89086 88.3661 10.1186C88.0706 10.3464 87.8629 10.5895 87.7428 10.848L86.7087 10.4787C86.8934 10.0478 87.1396 9.71236 87.4474 9.4723C87.7582 9.22917 88.0968 9.0599 88.463 8.96449C88.8323 8.866 89.1955 8.81676 89.5525 8.81676C89.7802 8.81676 90.0418 8.84446 90.3373 8.89986C90.6358 8.95218 90.9236 9.06143 91.2006 9.22763C91.4806 9.39382 91.713 9.64465 91.8977 9.98011C92.0823 10.3156 92.1747 10.7649 92.1747 11.3281V16H91.0852V15.0398H91.0298C90.9559 15.1937 90.8328 15.3583 90.6604 15.5337C90.4881 15.7092 90.2588 15.8584 89.9726 15.9815C89.6864 16.1046 89.3371 16.1662 88.9246 16.1662ZM89.0908 15.1875C89.5217 15.1875 89.8849 15.1029 90.1803 14.9336C90.4789 14.7643 90.7035 14.5458 90.8543 14.2781C91.0082 14.0103 91.0852 13.7287 91.0852 13.4332V12.4361C91.039 12.4915 90.9374 12.5423 90.7805 12.5884C90.6266 12.6315 90.4481 12.67 90.245 12.7038C90.0449 12.7346 89.8495 12.7623 89.6587 12.7869C89.4709 12.8085 89.3186 12.8269 89.2016 12.8423C88.9185 12.8793 88.6538 12.9393 88.4076 13.0224C88.1645 13.1024 87.9675 13.224 87.8167 13.3871C87.669 13.5471 87.5951 13.7656 87.5951 14.0426C87.5951 14.4212 87.7351 14.7074 88.0152 14.9013C88.2983 15.0921 88.6569 15.1875 89.0908 15.1875Z"
fill="white"
/>
<path
d="M82.2446 16.1477C81.5614 16.1477 80.972 15.9969 80.4765 15.6953C79.9841 15.3906 79.604 14.9659 79.3362 14.4212C79.0715 13.8733 78.9392 13.2363 78.9392 12.5099C78.9392 11.7836 79.0715 11.1435 79.3362 10.5895C79.604 10.0324 79.9764 9.59848 80.4534 9.28764C80.9335 8.97372 81.4937 8.81676 82.1338 8.81676C82.5031 8.81676 82.8678 8.87831 83.2279 9.00142C83.588 9.12453 83.9158 9.32457 84.2112 9.60156C84.5067 9.87547 84.7421 10.2386 84.9175 10.6911C85.093 11.1435 85.1807 11.7005 85.1807 12.3622V12.8239H79.7148V11.8821H84.0727C84.0727 11.482 83.9927 11.125 83.8327 10.8111C83.6757 10.4972 83.451 10.2494 83.1587 10.0678C82.8694 9.88624 82.5278 9.79545 82.1338 9.79545C81.6999 9.79545 81.3244 9.90317 81.0074 10.1186C80.6935 10.331 80.4519 10.608 80.2826 10.9496C80.1133 11.2912 80.0287 11.6574 80.0287 12.0483V12.6761C80.0287 13.2116 80.121 13.6656 80.3057 14.038C80.4934 14.4073 80.7535 14.6889 81.0859 14.8828C81.4183 15.0736 81.8045 15.169 82.2446 15.169C82.5308 15.169 82.7894 15.129 83.0202 15.049C83.2541 14.9659 83.4557 14.8428 83.6249 14.6797C83.7942 14.5135 83.925 14.3073 84.0173 14.0611L85.0699 14.3565C84.9591 14.7135 84.7729 15.0275 84.5113 15.2983C84.2497 15.5661 83.9265 15.7753 83.5418 15.9261C83.1571 16.0739 82.7247 16.1477 82.2446 16.1477Z"
fill="white"
/>
<path
d="M69.7963 16V6.54545H70.9412V10.7557H75.9823V6.54545H77.1272V16H75.9823V11.7713H70.9412V16H69.7963Z"
fill="white"
/>
<path
d="M59.4773 11.7344V16H58.3878V8.90909H59.4404V10.017H59.5327C59.6989 9.65696 59.9513 9.36766 60.2898 9.14915C60.6283 8.92756 61.0654 8.81676 61.6009 8.81676C62.081 8.81676 62.5011 8.91525 62.8612 9.11222C63.2213 9.30611 63.5013 9.60156 63.7014 9.99858C63.9014 10.3925 64.0015 10.8911 64.0015 11.4943V16H62.912V11.5682C62.912 11.0111 62.7673 10.5772 62.478 10.2663C62.1887 9.95241 61.7917 9.79545 61.287 9.79545C60.9392 9.79545 60.6283 9.87086 60.3544 10.0217C60.0836 10.1725 59.8697 10.3925 59.7127 10.6818C59.5558 10.9711 59.4773 11.322 59.4773 11.7344Z"
fill="white"
/>
<path
d="M53.7944 16.1477C53.1112 16.1477 52.5218 15.9969 52.0263 15.6953C51.5339 15.3906 51.1538 14.9659 50.886 14.4212C50.6214 13.8733 50.489 13.2363 50.489 12.5099C50.489 11.7836 50.6214 11.1435 50.886 10.5895C51.1538 10.0324 51.5262 9.59848 52.0032 9.28764C52.4833 8.97372 53.0435 8.81676 53.6836 8.81676C54.0529 8.81676 54.4176 8.87831 54.7777 9.00142C55.1378 9.12453 55.4656 9.32457 55.761 9.60156C56.0565 9.87547 56.2919 10.2386 56.4674 10.6911C56.6428 11.1435 56.7305 11.7005 56.7305 12.3622V12.8239H51.2646V11.8821H55.6225C55.6225 11.482 55.5425 11.125 55.3825 10.8111C55.2255 10.4972 55.0009 10.2494 54.7085 10.0678C54.4192 9.88624 54.0776 9.79545 53.6836 9.79545C53.2497 9.79545 52.8742 9.90317 52.5572 10.1186C52.2433 10.331 52.0017 10.608 51.8324 10.9496C51.6631 11.2912 51.5785 11.6574 51.5785 12.0483V12.6761C51.5785 13.2116 51.6708 13.6656 51.8555 14.038C52.0432 14.4073 52.3033 14.6889 52.6357 14.8828C52.9681 15.0736 53.3543 15.169 53.7944 15.169C54.0806 15.169 54.3392 15.129 54.57 15.049C54.8039 14.9659 55.0055 14.8428 55.1747 14.6797C55.344 14.5135 55.4748 14.3073 55.5671 14.0611L56.6197 14.3565C56.5089 14.7135 56.3227 15.0275 56.0611 15.2983C55.7995 15.5661 55.4763 15.7753 55.0916 15.9261C54.7069 16.0739 54.2745 16.1477 53.7944 16.1477Z"
fill="white"
/>
<path
d="M42.8995 18.6591V8.90909H43.9521V10.0355H44.0814C44.1614 9.9124 44.2722 9.75544 44.4137 9.56463C44.5584 9.37074 44.7646 9.19839 45.0323 9.04759C45.3032 8.8937 45.6694 8.81676 46.1311 8.81676C46.7281 8.81676 47.2544 8.96603 47.7099 9.26456C48.1654 9.56309 48.5209 9.98627 48.7763 10.5341C49.0318 11.0819 49.1595 11.7282 49.1595 12.473C49.1595 13.224 49.0318 13.8749 48.7763 14.4258C48.5209 14.9736 48.1669 15.3983 47.7145 15.6999C47.2621 15.9985 46.7404 16.1477 46.1495 16.1477C45.694 16.1477 45.3293 16.0723 45.0554 15.9215C44.7815 15.7676 44.5707 15.5937 44.423 15.3999C44.2752 15.2029 44.1614 15.0398 44.0814 14.9105H43.989V18.6591H42.8995ZM43.9706 12.4545C43.9706 12.9901 44.049 13.4625 44.206 13.8718C44.363 14.2781 44.5922 14.5966 44.8939 14.8274C45.1955 15.0552 45.5648 15.169 46.0018 15.169C46.4573 15.169 46.8374 15.049 47.1421 14.8089C47.4498 14.5658 47.6807 14.2396 47.8346 13.8303C47.9915 13.4178 48.07 12.9593 48.07 12.4545C48.07 11.956 47.9931 11.5066 47.8392 11.1065C47.6884 10.7034 47.4591 10.3848 47.1513 10.1509C46.8466 9.91394 46.4635 9.79545 46.0018 9.79545C45.5586 9.79545 45.1862 9.90779 44.8846 10.1325C44.583 10.354 44.3553 10.6649 44.2014 11.065C44.0475 11.462 43.9706 11.9252 43.9706 12.4545Z"
fill="white"
/>
<path
d="M41.1221 11.2727C41.1221 12.2699 40.9421 13.1316 40.582 13.858C40.2219 14.5843 39.7279 15.1444 39.1001 15.5384C38.4722 15.9323 37.7552 16.1293 36.9488 16.1293C36.1425 16.1293 35.4254 15.9323 34.7975 15.5384C34.1697 15.1444 33.6757 14.5843 33.3156 13.858C32.9556 13.1316 32.7755 12.2699 32.7755 11.2727C32.7755 10.2756 32.9556 9.41383 33.3156 8.6875C33.6757 7.96117 34.1697 7.40104 34.7975 7.0071C35.4254 6.61316 36.1425 6.41619 36.9488 6.41619C37.7552 6.41619 38.4722 6.61316 39.1001 7.0071C39.7279 7.40104 40.2219 7.96117 40.582 8.6875C40.9421 9.41383 41.1221 10.2756 41.1221 11.2727ZM40.0141 11.2727C40.0141 10.4541 39.8772 9.76314 39.6033 9.19993C39.3324 8.63672 38.9647 8.21046 38.4999 7.92116C38.0383 7.63187 37.5213 7.48722 36.9488 7.48722C36.3764 7.48722 35.8578 7.63187 35.3931 7.92116C34.9314 8.21046 34.5636 8.63672 34.2897 9.19993C34.0189 9.76314 33.8835 10.4541 33.8835 11.2727C33.8835 12.0914 34.0189 12.7823 34.2897 13.3455C34.5636 13.9087 34.9314 14.335 35.3931 14.6243C35.8578 14.9136 36.3764 15.0582 36.9488 15.0582C37.5213 15.0582 38.0383 14.9136 38.4999 14.6243C38.9647 14.335 39.3324 13.9087 39.6033 13.3455C39.8772 12.7823 40.0141 12.0914 40.0141 11.2727Z"
fill="white"
/>
<path
d="M21.5 3C21.5 2.17157 20.8284 1.5 20 1.5H15C14.1716 1.5 13.5 2.17157 13.5 3V8C13.5 8.82843 14.1716 9.5 15 9.5H20C20.8284 9.5 21.5 8.82843 21.5 8V3ZM22.5 8C22.5 9.38071 21.3807 10.5 20 10.5H15C13.6193 10.5 12.5 9.38071 12.5 8V3C12.5 1.61929 13.6193 0.5 15 0.5H20C21.3807 0.5 22.5 1.61929 22.5 3V8Z"
fill="#358CFD"
/>
<path
d="M9.5 3C9.5 2.17157 8.82843 1.5 8 1.5H3C2.17157 1.5 1.5 2.17157 1.5 3V8C1.5 8.82843 2.17157 9.5 3 9.5H8C8.82843 9.5 9.5 8.82843 9.5 8V3ZM10.5 8C10.5 9.38071 9.38071 10.5 8 10.5H3C1.61929 10.5 0.5 9.38071 0.5 8V3C0.5 1.61929 1.61929 0.5 3 0.5H8C9.38071 0.5 10.5 1.61929 10.5 3V8Z"
fill="#358CFD"
/>
<path
d="M9.5 15C9.5 14.1716 8.82843 13.5 8 13.5H3C2.17157 13.5 1.5 14.1716 1.5 15V20C1.5 20.8284 2.17157 21.5 3 21.5H8C8.82843 21.5 9.5 20.8284 9.5 20V15ZM10.5 20C10.5 21.3807 9.38071 22.5 8 22.5H3C1.61929 22.5 0.5 21.3807 0.5 20V15C0.5 13.6193 1.61929 12.5 3 12.5H8C9.38071 12.5 10.5 13.6193 10.5 15V20Z"
fill="#358CFD"
/>
<path
d="M21.5 15C21.5 14.1716 20.8284 13.5 20 13.5H15C14.1716 13.5 13.5 14.1716 13.5 15V20C13.5 20.8284 14.1716 21.5 15 21.5H20C20.8284 21.5 21.5 20.8284 21.5 20V15ZM22.5 20C22.5 21.3807 21.3807 22.5 20 22.5H15C13.6193 22.5 12.5 21.3807 12.5 20V15C12.5 13.6193 13.6193 12.5 15 12.5H20C21.3807 12.5 22.5 13.6193 22.5 15V20Z"
fill="#358CFD"
/>
</svg>
);
export default OHIFLogoHorizontal;
@@ -0,0 +1,29 @@
import React from 'react';
import type { IconProps } from '../types';
export const PanelRight = (props: IconProps) => (
<svg
width="19"
height="16"
viewBox="0 0 19 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
x="-0.5"
y="0.5"
width="18"
height="15"
rx="1.5"
transform="matrix(-1 0 0 1 18 0)"
stroke="#348CFD"
/>
<path
d="M17 0.5H13.5V15.5H17C17.8284 15.5 18.5 14.8284 18.5 14V2C18.5 1.17157 17.8284 0.5 17 0.5Z"
stroke="#348CFD"
/>
</svg>
);
export default PanelRight;
@@ -0,0 +1,62 @@
import React from 'react';
import type { IconProps } from '../types';
const PatientStudyList = (props: IconProps) => (
<svg
width="33"
height="33"
viewBox="0 0 33 33"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#patient-study-list_svg__a)">
<path
d="M0.6875 8.9375V4.8125C0.6875 3.71848 1.1221 2.66927 1.89568 1.89568 2.66927 1.1221 3.71848 0.6875 4.8125 0.6875H8.9375"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M32.3125 8.9375V4.8125C32.3125 3.71848 31.8779 2.66927 31.1043 1.89568 30.3307 1.1221 29.2815 0.6875 28.1875 0.6875H24.0625"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M0.6875 24.0625V28.1875C0.6875 29.2815 1.1221 30.3307 1.89568 31.1043 2.66927 31.8779 3.71848 32.3125 4.8125 32.3125H8.9375"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M32.3125 24.0625V28.1875C32.3125 29.2815 31.8779 30.3307 31.1043 31.1043 30.3307 31.8779 29.2815 32.3125 28.1875 32.3125H24.0625"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5.4436 26.2446C7.79153 24.8832 10.2891 23.798 12.8865 23.0106 14.0374 22.5844 14.1625 19.9458 13.3375 19.0369 12.1467 17.7279 11.1375 16.192 11.1375 12.485 11.0681 11.7465 11.1563 11.0017 11.3962 10.2997 11.6361 9.59783 12.0223 8.95483 12.5291 8.41325 13.036 7.87167 13.6521 7.44384 14.3366 7.15806 15.0211 6.87227 15.7585 6.73502 16.5 6.7554 17.2415 6.73502 17.9789 6.87227 18.6634 7.15806 19.3479 7.44384 19.9639 7.87167 20.4708 8.41325 20.9777 8.95483 21.3639 9.59783 21.6038 10.2997 21.8437 11.0017 21.9318 11.7465 21.8625 12.485 21.8625 16.1975 20.8532 17.7279 19.6625 19.0369 18.8375 19.9458 18.9626 22.5844 20.1135 23.0106 22.7109 23.798 25.2084 24.8832 27.5564 26.2446"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="patient-study-list_svg__a">
<rect
width={33}
height={33}
fill="#fff"
/>
</clipPath>
</defs>
</svg>
);
export default PatientStudyList;
@@ -0,0 +1,40 @@
import React from 'react';
import type { IconProps } from '../types';
export const SeriesPlaceholder = (props: IconProps) => (
<svg
width="75px"
height="50px"
viewBox="0 0 75 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect
opacity="0.3"
x="1"
y="1"
width="73"
height="48"
rx="11"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M30.7059 31H25.1176C24.8212 31 24.537 30.8822 24.3274 30.6726C24.1178 30.4631 24 30.1788 24 29.8824V13.1176C24 12.8212 24.1178 12.537 24.3274 12.3274C24.537 12.1178 24.8212 12 25.1176 12H41.8824C42.1788 12 42.4631 12.1178 42.6726 12.3274C42.8822 12.537 43 12.8212 43 13.1176V18.7059"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M47.9412 19H32.0588C31.4741 19 31 19.4741 31 20.0588V35.9412C31 36.5259 31.4741 37 32.0588 37H47.9412C48.5259 37 49 36.5259 49 35.9412V20.0588C49 19.4741 48.5259 19 47.9412 19Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SeriesPlaceholder;
@@ -0,0 +1,28 @@
import React from 'react';
import type { IconProps } from '../types';
export const SettingsStudyList = (props: IconProps) => (
<svg
width="17"
height="17"
viewBox="0 0 17 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M14.0113 5.16848C13.9329 5.33325 13.8899 5.51264 13.8853 5.69504C13.8806 5.87745 13.9142 6.0588 13.9841 6.22738C14.0539 6.39595 14.1583 6.54799 14.2906 6.67365C14.4229 6.79931 14.5801 6.8958 14.7521 6.95687L15.632 7.26989C15.8859 7.36002 16.1057 7.52657 16.2611 7.74666C16.4166 7.96676 16.5 8.22958 16.5 8.49902C16.5 8.76846 16.4166 9.03129 16.2611 9.25138C16.1057 9.47147 15.8859 9.63803 15.632 9.72815L14.7521 10.0412C14.5801 10.1022 14.4229 10.1987 14.2906 10.3244C14.1583 10.4501 14.0539 10.6021 13.9841 10.7707C13.9142 10.9392 13.8806 11.1206 13.8853 11.303C13.8899 11.4854 13.9329 11.6648 14.0113 11.8296L14.4133 12.6719C14.5295 12.9153 14.5675 13.1887 14.5219 13.4546C14.4763 13.7204 14.3494 13.9656 14.1586 14.1563C13.9679 14.347 13.7228 14.4739 13.4569 14.5195C13.1911 14.5651 12.9177 14.5272 12.6743 14.4109L11.8305 14.0103C11.6658 13.9319 11.4864 13.889 11.304 13.8843C11.1216 13.8796 10.9402 13.9133 10.7716 13.9831C10.6031 14.0529 10.451 14.1574 10.3254 14.2897C10.1997 14.4219 10.1032 14.5792 10.0421 14.7511L9.72913 15.631C9.63886 15.8847 9.47229 16.1042 9.25228 16.2595C9.03227 16.4147 8.76961 16.498 8.50035 16.498C8.23109 16.498 7.96842 16.4147 7.74841 16.2595C7.52841 16.1042 7.36183 15.8847 7.27157 15.631L6.95855 14.7511C6.8974 14.5791 6.80083 14.4219 6.67511 14.2896C6.54938 14.1573 6.39728 14.0529 6.22865 13.983C6.06002 13.9132 5.87862 13.8796 5.69617 13.8843C5.51371 13.8889 5.33428 13.9319 5.16946 14.0103L4.32709 14.4109C4.08371 14.5276 3.81011 14.5659 3.54406 14.5205C3.27801 14.475 3.03262 14.3481 2.84177 14.1573C2.65091 13.9664 2.52401 13.721 2.47857 13.455C2.43313 13.1889 2.47139 12.9153 2.58808 12.6719L2.98875 11.8282C3.06714 11.6634 3.11006 11.484 3.11475 11.3016C3.11943 11.1192 3.08577 10.9379 3.01594 10.7693C2.94611 10.6007 2.84167 10.4487 2.70937 10.323C2.57708 10.1973 2.41987 10.1009 2.24793 10.0398L1.368 9.72676C1.11408 9.63663 0.8943 9.47008 0.738874 9.24999C0.583448 9.0299 0.5 8.76707 0.5 8.49763C0.5 8.22819 0.583448 7.96536 0.738874 7.74527C0.8943 7.52518 1.11408 7.35863 1.368 7.2685L2.24793 6.95548C2.41969 6.89438 2.57673 6.79794 2.70889 6.67238C2.84106 6.54681 2.94542 6.39492 3.01524 6.22652C3.08505 6.05811 3.11876 5.87694 3.1142 5.69469C3.10964 5.51244 3.0669 5.33318 2.98875 5.16848L2.58808 4.32472C2.47139 4.08134 2.43313 3.80774 2.47857 3.54169C2.52401 3.27564 2.65091 3.03025 2.84177 2.8394C3.03262 2.64854 3.27801 2.52164 3.54406 2.4762C3.81011 2.43076 4.08371 2.46902 4.32709 2.58571L5.17085 2.98638C5.33551 3.06457 5.51474 3.10737 5.69696 3.11203C5.87919 3.11669 6.06037 3.0831 6.22881 3.01343C6.39725 2.94376 6.54922 2.83955 6.67491 2.70754C6.80061 2.57552 6.89723 2.41861 6.95855 2.24695L7.27157 1.36702C7.36183 1.11334 7.52841 0.893811 7.74841 0.738574C7.96842 0.583338 8.23109 0.5 8.50035 0.5C8.76961 0.5 9.03227 0.583338 9.25228 0.738574C9.47229 0.893811 9.63886 1.11334 9.72913 1.36702L10.0421 2.24695C10.1032 2.41889 10.1997 2.5761 10.3254 2.70839C10.451 2.84069 10.6031 2.94513 10.7716 3.01496C10.9402 3.08479 11.1216 3.11845 11.304 3.11377C11.4864 3.10908 11.6658 3.06616 11.8305 2.98777L12.6743 2.58571C12.9177 2.46948 13.1911 2.43155 13.4569 2.47716C13.7228 2.52277 13.9679 2.64967 14.1586 2.84038C14.3494 3.0311 14.4763 3.27625 14.5219 3.54208C14.5675 3.80791 14.5295 4.08134 14.4133 4.32472L14.0113 5.16848Z"
stroke="#358CFD"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8.49691 11.6292C10.2257 11.6292 11.6271 10.2277 11.6271 8.49898C11.6271 6.77022 10.2257 5.36877 8.49691 5.36877C6.76814 5.36877 5.3667 6.77022 5.3667 8.49898C5.3667 10.2277 6.76814 11.6292 8.49691 11.6292Z"
stroke="#358CFD"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SettingsStudyList;
@@ -0,0 +1,20 @@
import React from 'react';
import type { IconProps } from '../types';
export const SortingNew = (props: IconProps) => (
<svg
width="10"
height="16"
viewBox="0 0 10 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5.0293 1.5C5.1141 1.50007 5.19938 1.51765 5.28418 1.55273C5.36895 1.58496 5.44053 1.63194 5.49902 1.69336L8.36035 4.55859C8.49202 4.69037 8.55762 4.84288 8.55762 5.01562C8.55762 5.19434 8.49902 5.34082 8.38184 5.45508C8.26758 5.56934 8.12402 5.62695 7.95117 5.62695C7.85742 5.62695 7.77246 5.60742 7.69629 5.56934C7.62022 5.53423 7.55464 5.48719 7.49902 5.42871L6.50586 4.44434L5.62305 3.4248L5.66211 4.37891V11.6963L5.62305 12.6484L6.50586 11.6299L7.49902 10.6455C7.55464 10.587 7.62022 10.54 7.69629 10.5049C7.77246 10.4668 7.85742 10.4482 7.95117 10.4482C8.12402 10.4482 8.26758 10.5049 8.38184 10.6191C8.49902 10.7334 8.55762 10.8799 8.55762 11.0586C8.55762 11.2313 8.49202 11.3838 8.36035 11.5156L5.49902 14.3809C5.44053 14.4423 5.36895 14.4893 5.28418 14.5215C5.19938 14.5566 5.1141 14.5742 5.0293 14.5742C4.94141 14.5742 4.85449 14.5566 4.76953 14.5215C4.6875 14.4893 4.61719 14.4424 4.55859 14.3809L1.69824 11.5156C1.56641 11.3838 1.5 11.2314 1.5 11.0586C1.5 10.8799 1.55762 10.7334 1.67188 10.6191C1.78902 10.505 1.93372 10.4482 2.10645 10.4482C2.2002 10.4482 2.28418 10.4668 2.35742 10.5049C2.43343 10.54 2.50108 10.587 2.55957 10.6455L3.54785 11.6299L4.42969 12.6484L4.3916 11.6963V4.37891L4.42969 3.4248L3.54785 4.44434L2.55957 5.42871C2.50108 5.4872 2.43343 5.5342 2.35742 5.56934C2.28418 5.60742 2.2002 5.62695 2.10645 5.62695C1.93372 5.62695 1.78902 5.56917 1.67188 5.45508C1.55762 5.34082 1.5 5.19434 1.5 5.01562C1.5 4.84277 1.56641 4.69043 1.69824 4.55859L4.55859 1.69336C4.61719 1.63184 4.6875 1.58496 4.76953 1.55273C4.85449 1.51758 4.94141 1.5 5.0293 1.5Z"
fill="currentColor"
/>
</svg>
);
export default SortingNew;
@@ -0,0 +1,35 @@
import React from 'react';
import type { IconProps } from '../types';
export const SortingNewAscending = (props: IconProps) => (
<svg
width="10"
height="16"
viewBox="0 0 10 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<line
x1="0.75"
y1="-0.75"
x2="4.90685"
y2="-0.75"
transform="matrix(0.707107 -0.707107 -0.707107 -0.707107 1 8)"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="7.93934"
y1="8"
x2="5"
y2="5.06066"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
export default SortingNewAscending;
@@ -0,0 +1,35 @@
import React from 'react';
import type { IconProps } from '../types';
export const SortingNewDescending = (props: IconProps) => (
<svg
width="10"
height="16"
viewBox="0 0 10 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<line
x1="2.06066"
y1="8"
x2="5"
y2="10.9393"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="0.75"
y1="-0.75"
x2="4.90685"
y2="-0.75"
transform="matrix(-0.707107 0.707107 0.707107 0.707107 9 8)"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
export default SortingNewDescending;
@@ -0,0 +1,364 @@
/**
* Compound multi-select chip-input built on cmdk.
*
* <InputMultiSelect options={...} value={...} onChange={...}>
* <InputMultiSelect.Field>
* <InputMultiSelect.Summary />
* <InputMultiSelect.Input ariaLabel="..." />
* </InputMultiSelect.Field>
* <InputMultiSelect.Content fitToContent maxWidth={185}>
* <InputMultiSelect.Options />
* </InputMultiSelect.Content>
* </InputMultiSelect>
*/
import * as React from 'react';
import { createPortal } from 'react-dom';
import { cn } from '../../lib/utils';
import { Command as CommandPrimitive } from 'cmdk';
import { CommandList, CommandGroup, CommandItem, CommandEmpty } from '../Command/Command';
import { Badge } from '../Badge';
import { Icons } from '../Icons';
import { ScrollArea } from '../ScrollArea';
type Option = string | { value: string; label?: string };
type NormalizedOption = { value: string; label: string };
type Coords = { left: number; top: number; width: number; maxHeight: number };
function normalizeOption(opt: Option): NormalizedOption {
if (typeof opt === 'string') return { value: opt, label: opt };
return { value: opt.value, label: opt.label ?? opt.value };
}
type IMSContext = {
value: string[];
normalized: NormalizedOption[];
selectedSet: Set<string>;
query: string;
setQuery: (s: string) => void;
open: boolean;
setOpen: (b: boolean) => void;
fieldRef: React.MutableRefObject<HTMLDivElement | null>;
overlayRef: React.MutableRefObject<HTMLDivElement | null>;
inputRef: React.MutableRefObject<HTMLInputElement | null>;
coords: Coords | null;
filtered: NormalizedOption[];
toggle: (val: string) => void;
remove: (val: string) => void;
clear: () => void;
};
const InputMultiSelectContext = React.createContext<IMSContext | null>(null);
function useInputMultiSelect(): IMSContext {
const ctx = React.useContext(InputMultiSelectContext);
if (!ctx) throw new Error('useInputMultiSelect must be used within <InputMultiSelect>');
return ctx;
}
export type InputMultiSelectRootProps = {
options: Option[];
value: string[];
onChange: (next: string[]) => void;
children?: React.ReactNode;
};
function InputMultiSelectRoot({ options, value, onChange, children }: InputMultiSelectRootProps) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const fieldRef = React.useRef<HTMLDivElement | null>(null);
const overlayRef = React.useRef<HTMLDivElement | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const selectedSet = React.useMemo(() => new Set(value), [value]);
const normalized = React.useMemo(() => options.map(normalizeOption), [options]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return normalized;
return normalized.filter(
opt => opt.label.toLowerCase().includes(q) || opt.value.toLowerCase().includes(q)
);
}, [normalized, query]);
React.useEffect(() => {
function handleDoc(event: MouseEvent) {
const target = event.target as Node | null;
if (!target) return;
if (containerRef.current?.contains(target)) return;
if (overlayRef.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener('mousedown', handleDoc);
return () => document.removeEventListener('mousedown', handleDoc);
}, []);
const [coords, setCoords] = React.useState<Coords | null>(null);
// Prefer placing the overlay below the field; flip above if there's more space upward.
const measure = React.useCallback(() => {
const anchor = fieldRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const gutter = 8;
const bottomSpace = window.innerHeight - rect.bottom;
const topSpace = rect.top;
const preferBelow = bottomSpace >= topSpace;
const available = preferBelow ? bottomSpace : topSpace;
const maxHeight = Math.max(120, Math.min(300, available - gutter));
const top = preferBelow ? rect.bottom : Math.max(0, rect.top - maxHeight);
setCoords({ left: rect.left, top, width: rect.width, maxHeight });
}, []);
React.useLayoutEffect(() => {
if (open) measure();
}, [open, measure, query, value]);
React.useEffect(() => {
if (!open) return;
const handler = () => measure();
window.addEventListener('resize', handler);
// Capture phase catches scrolls in nested scrollable ancestors.
window.addEventListener('scroll', handler, true);
return () => {
window.removeEventListener('resize', handler);
window.removeEventListener('scroll', handler, true);
};
}, [open, measure]);
const remove = React.useCallback(
(val: string) => onChange(value.filter(v => v !== val)),
[value, onChange]
);
const toggle = React.useCallback(
(val: string) => {
const next = selectedSet.has(val) ? value.filter(v => v !== val) : [...value, val];
onChange(next);
setQuery('');
},
[selectedSet, value, onChange]
);
const clear = React.useCallback(() => onChange([]), [onChange]);
const ctx: IMSContext = {
value,
normalized,
selectedSet,
query,
setQuery,
open,
setOpen,
fieldRef,
overlayRef,
inputRef,
coords,
filtered,
toggle,
remove,
clear,
};
return (
<InputMultiSelectContext.Provider value={ctx}>
{/* shouldFilter={false} so we own filtering and can sort selected items to the top. */}
<CommandPrimitive
className="h-auto overflow-visible bg-transparent"
shouldFilter={false}
>
<div
ref={containerRef}
className="relative"
>
{children}
</div>
</CommandPrimitive>
</InputMultiSelectContext.Provider>
);
}
function Field({ children }: { children?: React.ReactNode }) {
const { fieldRef, inputRef } = useInputMultiSelect();
return (
<div
ref={fieldRef}
className="border-input text-foreground bg-background hover:bg-primary/10 focus-within:ring-ring flex h-7 w-full items-center gap-1 rounded border px-2 py-1 text-base shadow-sm transition-colors focus-within:outline-none focus-within:ring-1"
role="group"
onClick={() => inputRef.current?.focus()}
>
{children}
</div>
);
}
Field.displayName = 'InputMultiSelect.Field';
function Summary() {
const { value, normalized, clear } = useInputMultiSelect();
if (value.length === 0) return null;
const firstLabel = normalized.find(o => o.value === value[0])?.label ?? value[0];
const text = value.length > 1 ? String(value.length) : firstLabel;
return (
<Badge
variant="default"
className="inline-flex h-5 items-center gap-1 shrink-0 px-2"
>
<span
className="truncate max-w-[160px]"
title={firstLabel}
>
{text}
</span>
<span
role="button"
tabIndex={0}
aria-label="Clear all selections"
className="cursor-pointer select-none opacity-80 hover:opacity-100"
onClick={() => clear()}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clear();
}
}}
>
×
</span>
</Badge>
);
}
Summary.displayName = 'InputMultiSelect.Summary';
type IMSInputProps = {
ariaLabel?: string;
placeholder?: string;
};
function IMSInput({ ariaLabel, placeholder }: IMSInputProps) {
const { inputRef, value, query, setQuery, open, setOpen, remove } = useInputMultiSelect();
return (
<CommandPrimitive.Input
ref={node => {
inputRef.current = node;
}}
aria-label={ariaLabel}
placeholder={value.length === 0 ? placeholder : ''}
className="h-5 min-w-0 flex-1 bg-transparent px-0 py-0 outline-none"
value={query}
onValueChange={v => {
setQuery(v);
if (!open) setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={e => {
if (e.key === 'Escape') setOpen(false);
// Backspace on empty query removes the most recently selected value.
if (e.key === 'Backspace' && query === '' && value.length > 0) {
remove(value[value.length - 1]);
}
}}
/>
);
}
IMSInput.displayName = 'InputMultiSelect.Input';
type ContentProps = {
children?: React.ReactNode;
fitToContent?: boolean;
maxWidth?: number;
};
function Content({ children, fitToContent = false, maxWidth }: ContentProps) {
const { open, coords, overlayRef, setOpen } = useInputMultiSelect();
if (!(open && coords)) return null;
const gutter = 8;
const viewportMaxWidth = Math.max(200, window.innerWidth - coords.left - gutter);
const computedMaxWidth = Math.min(maxWidth ?? 480, viewportMaxWidth);
return createPortal(
<div
ref={overlayRef}
className="z-[1000] mt-1 rounded-md border border-input bg-popover shadow-md"
style={{
position: 'fixed',
left: coords.left,
top: coords.top,
width: fitToContent ? 'auto' : coords.width,
minWidth: coords.width,
maxWidth: fitToContent ? computedMaxWidth : undefined,
maxHeight: coords.maxHeight,
overflow: 'hidden',
}}
onKeyDown={e => {
if (e.key === 'Escape') setOpen(false);
}}
>
<ScrollArea
className="h-auto min-h-0 max-h-[inherit]"
type="auto"
>
<CommandList
className="max-h-none overflow-visible"
role="listbox"
aria-multiselectable={true}
>
{children}
</CommandList>
</ScrollArea>
</div>,
document.body
);
}
Content.displayName = 'InputMultiSelect.Content';
function Options() {
const { filtered, toggle, selectedSet } = useInputMultiSelect();
// Selected items appear at the top so the user can quickly see and uncheck them.
const selected = filtered.filter(o => selectedSet.has(o.value));
const unselected = filtered.filter(o => !selectedSet.has(o.value));
const ordered = [...selected, ...unselected];
return (
<CommandGroup>
{filtered.length === 0 ? (
<CommandEmpty>No options found.</CommandEmpty>
) : (
ordered.map(opt => (
<CommandItem
key={opt.value}
onSelect={() => toggle(opt.value)}
aria-selected={selectedSet.has(opt.value)}
className="min-w-0 leading-none"
>
<span
className="truncate leading-none"
title={opt.label}
>
{opt.label}
</span>
<Icons.Checked
className={cn(
'ml-auto block h-6 w-6 shrink-0',
selectedSet.has(opt.value) ? 'opacity-70' : 'invisible'
)}
aria-hidden="true"
/>
</CommandItem>
))
)}
</CommandGroup>
);
}
Options.displayName = 'InputMultiSelect.Options';
const InputMultiSelect = Object.assign(InputMultiSelectRoot, {
Field,
Summary,
Input: IMSInput,
Content,
Options,
});
export { InputMultiSelect };
export type { Option };
@@ -0,0 +1 @@
export { InputMultiSelect } from './InputMultiSelect'
@@ -18,6 +18,7 @@ const PopoverContent = React.forwardRef<
ref={ref}
align={align}
sideOffset={sideOffset}
data-slot="popover-content"
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
@@ -0,0 +1,257 @@
import * as React from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '../../DataTable';
import type { ColumnMeta } from '../../DataTable/types';
import { Icons } from '../../Icons';
import type { StudyDateRangeFilter, StudyRow } from '../types/types';
import { ActionCell } from '../components/ActionCell';
import { tokenizeModalities } from '../utils/tokenizeModalities';
import { formatDICOMDate } from '../../../utils/formatDICOMDate';
import { formatDICOMTime } from '../../../utils/formatDICOMTime';
import { parseStudyDateTimestamp } from '../../../utils/parseStudyDateTimestamp';
// Column ID constants - shared across the codebase
export const COLUMN_IDS = {
PATIENT: 'patient',
MRN: 'mrn',
STUDY_DATE_TIME: 'studyDateTime',
MODALITIES: 'modalities',
DESCRIPTION: 'description',
ACCESSION: 'accession',
INSTANCES: 'instances',
ACTIONS: 'actions',
} as const;
// Filterable column IDs (columns that support filtering)
export const FILTERABLE_COLUMN_IDS = [
COLUMN_IDS.PATIENT,
COLUMN_IDS.MRN,
COLUMN_IDS.MODALITIES,
COLUMN_IDS.DESCRIPTION,
COLUMN_IDS.ACCESSION,
] as const;
// Text filter column IDs (columns that use text-based filtering)
export const TEXT_FILTER_COLUMN_IDS = [
COLUMN_IDS.PATIENT,
COLUMN_IDS.MRN,
COLUMN_IDS.ACCESSION,
COLUMN_IDS.DESCRIPTION,
] as const;
/**
* Creates a basic, display-only text column. Use this to add simple columns
* declaratively (e.g. via the `workList.columns` customization) without
* writing the accessor/header/cell wiring by hand.
*/
export function textColumn(
id: string,
label: string,
meta?: Partial<ColumnMeta>
): ColumnDef<StudyRow, unknown> {
return {
id,
accessorFn: row => (row as Record<string, unknown>)[id] ?? '',
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue<string>(id)}</div>,
meta: { label, minWidth: 120, ...meta },
};
}
export const defaultColumns: ColumnDef<StudyRow, unknown>[] = [
{
id: COLUMN_IDS.PATIENT,
accessorFn: row => {
const r = row as StudyRow;
return r.patientName ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue(COLUMN_IDS.PATIENT)}</div>,
meta: {
label: 'Patient',
headerClassName: 'min-w-[165px]',
cellClassName: 'min-w-[165px]',
minWidth: 165,
priority: 100,
},
},
{
id: COLUMN_IDS.MRN,
accessorFn: row => {
const r = row as StudyRow;
return r.mrn ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue(COLUMN_IDS.MRN)}</div>,
meta: {
label: 'MRN',
headerClassName: 'min-w-[120px]',
cellClassName: 'min-w-[120px]',
minWidth: 120,
priority: 20,
},
},
{
id: COLUMN_IDS.STUDY_DATE_TIME,
accessorFn: row => {
const r = row as StudyRow;
// Date drives the cell: with no valid date we show nothing, even if a
// time is present.
const date = formatDICOMDate(r.date ?? '', {
fallbackFormat: 'MMM-DD-YYYY',
invalidFallback: '',
});
if (!date) {
return '';
}
const time = formatDICOMTime(r.time ?? '', { invalidFallback: '' });
return time ? `${date} ${time}` : date;
},
filterFn: (row, _colId, filter) => {
const range =
filter && typeof filter === 'object' ? (filter as StudyDateRangeFilter) : undefined;
const startDate = range?.startDate;
const endDate = range?.endDate;
if (!startDate && !endDate) {
return true;
}
const rowDate = String((row.original as StudyRow).date ?? '').replace(/\D/g, '');
if (!rowDate) {
return false;
}
if (startDate && rowDate < startDate) {
return false;
}
if (endDate && rowDate > endDate) {
return false;
}
return true;
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => {
return <div className="truncate">{row.getValue(COLUMN_IDS.STUDY_DATE_TIME)}</div>;
},
sortingFn: (a, b) => {
const aRow = a.original as StudyRow;
const bRow = b.original as StudyRow;
const aTimestamp = parseStudyDateTimestamp(aRow.date ?? '', aRow.time ?? '');
const bTimestamp = parseStudyDateTimestamp(bRow.date ?? '', bRow.time ?? '');
return aTimestamp - bTimestamp;
},
meta: {
label: 'Study Date',
headerClassName: 'min-w-[195px]',
cellClassName: 'min-w-[195px]',
minWidth: 195,
priority: 70,
},
},
{
id: COLUMN_IDS.MODALITIES,
accessorFn: row => {
const r = row as StudyRow;
return r.modalities ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue(COLUMN_IDS.MODALITIES)}</div>,
filterFn: (row, colId, filter) => {
const selected = Array.isArray(filter) ? (filter as string[]) : [];
if (!selected.length) {
return true;
}
const tokens = tokenizeModalities(String(row.getValue(colId) ?? ''));
const set = new Set(tokens);
return selected.some(v => set.has(String(v).toUpperCase()));
},
meta: {
label: 'Modalities',
headerClassName: 'min-w-[97px]',
cellClassName: 'min-w-[97px]',
minWidth: 97,
priority: 60,
},
},
{
id: COLUMN_IDS.DESCRIPTION,
accessorFn: row => {
const r = row as StudyRow;
return r.description ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => {
const description = row.getValue(COLUMN_IDS.DESCRIPTION) as string;
return (
<div className={!description ? 'text-muted-foreground/40' : ''}>
{description || 'No Description'}
</div>
);
},
meta: {
label: 'Description',
headerClassName: 'min-w-[290px]',
cellClassName: 'min-w-[290px]',
minWidth: 290,
priority: 90,
},
},
{
id: COLUMN_IDS.ACCESSION,
accessorFn: row => {
const r = row as StudyRow;
return r.accession ?? '';
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => <div className="truncate">{row.getValue(COLUMN_IDS.ACCESSION)}</div>,
meta: {
label: 'Accession',
headerClassName: 'min-w-[140px]',
cellClassName: 'min-w-[140px]',
minWidth: 140,
priority: 30,
},
},
{
id: COLUMN_IDS.INSTANCES,
accessorFn: row => {
const r = row as StudyRow;
return Number(r.instances ?? 0);
},
header: ({ column }) => <DataTable.ColumnHeader column={column} />,
cell: ({ row }) => {
const value = row.getValue(COLUMN_IDS.INSTANCES) as number;
return <div className="text-right">{value}</div>;
},
sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number),
meta: {
label: 'Instances',
headerContent: (
<Icons.Series
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
),
align: 'right',
headerClassName: 'min-w-[45px]',
cellClassName: 'min-w-[45px] overflow-hidden',
minWidth: 45,
priority: 50,
},
},
// Non-hideable trailing actions column to keep the menu at row end
{
id: COLUMN_IDS.ACTIONS,
header: () => null,
enableSorting: false,
enableHiding: false,
cell: ({ cell }) => <ActionCell cell={cell} />,
meta: {
// No label so it never appears labeled in any UI; also non-hideable
headerClassName: 'w-[56px] min-w-[56px] max-w-[56px]',
cellClassName: 'w-[56px] min-w-[56px] max-w-[56px] overflow-visible',
minWidth: 56,
},
},
];
@@ -0,0 +1,19 @@
import * as React from 'react';
import type { Cell } from '@tanstack/react-table';
import { DataTable } from '../../DataTable';
import { WorkflowMenu } from './WorkflowMenu';
import type { StudyRow } from '../types/types';
export function ActionCell({ cell }: { cell: Cell<StudyRow, unknown> }) {
const original = cell.row.original as StudyRow;
return (
<DataTable.ActionOverlayCell cell={cell}>
<DataTable.ActionOverlayCell.Overlay>
<div onClick={e => e.stopPropagation()}>
<WorkflowMenu studyRow={original} />
</div>
</DataTable.ActionOverlayCell.Overlay>
</DataTable.ActionOverlayCell>
);
}
@@ -0,0 +1,210 @@
import * as React from 'react';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '../../Resizable';
import { Table as StudyListTable, type TableProps as StudyListTableProps } from './Table';
import { WorkflowsProvider, type Mode } from './WorkflowsProvider';
type LayoutContextValue = {
isPreviewOpen: boolean;
openPreview: () => void;
closePreview: () => void;
defaultPreviewSizePercent: number;
minPreviewSizePercent: number;
};
const LayoutContext = React.createContext<LayoutContextValue | undefined>(undefined);
export function useLayout() {
const ctx = React.useContext(LayoutContext);
if (!ctx) {
throw new Error('useLayout must be used within <StudyList>');
}
return ctx;
}
export type LayoutProps = {
isPreviewOpen: boolean;
onIsPreviewOpenChange: (open: boolean) => void;
defaultPreviewSizePercent: number;
minPreviewSizePercent?: number;
className?: string;
children?: React.ReactNode;
/** Array of loaded modes from appConfig */
loadedModes?: Mode[];
/** Optional data path prefix for routes (e.g., '/dicomweb') */
dataPath?: string;
/** Function to preserve query parameters when launching workflows */
preserveQueryParameters: (query: URLSearchParams) => void;
};
function LayoutRoot({
isPreviewOpen,
onIsPreviewOpenChange,
defaultPreviewSizePercent,
minPreviewSizePercent = 15,
className,
loadedModes = [],
preserveQueryParameters,
dataPath,
children,
}: LayoutProps) {
let tableChild: React.ReactNode = null;
let previewChild: React.ReactNode = null;
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
const childType = child.type;
if (childType === Table) {
if (tableChild) {
throw new Error('StudyList can only contain one Table component.');
}
tableChild = child;
} else if (childType === Preview) {
if (previewChild) {
throw new Error('StudyList can only contain one Preview component.');
}
previewChild = child;
} else {
throw new Error('StudyList can only contain Table and Preview components.');
}
}
});
if (!tableChild) {
throw new Error('StudyList must contain a Table component.');
}
const openPreview = React.useCallback(() => onIsPreviewOpenChange(true), [onIsPreviewOpenChange]);
const closePreview = React.useCallback(
() => onIsPreviewOpenChange(false),
[onIsPreviewOpenChange]
);
const value = React.useMemo<LayoutContextValue>(
() => ({
isPreviewOpen,
openPreview,
closePreview,
defaultPreviewSizePercent,
minPreviewSizePercent,
}),
[isPreviewOpen, openPreview, closePreview, defaultPreviewSizePercent, minPreviewSizePercent]
);
return (
<WorkflowsProvider
loadedModes={loadedModes}
preserveQueryParameters={preserveQueryParameters}
dataPath={dataPath}
>
<LayoutContext.Provider value={value}>
<ResizablePanelGroup
direction="horizontal"
className={className ?? 'h-full w-full'}
>
{tableChild}
{previewChild}
</ResizablePanelGroup>
</LayoutContext.Provider>
</WorkflowsProvider>
);
}
type TableProps = StudyListTableProps & {
children?: React.ReactNode; // If provided, overrides table rendering (for custom content)
};
function Table({
columns,
data,
title,
initialVisibility,
enforceSingleSelection,
showColumnVisibility,
tableClassName,
onSelectionChange,
onSortingChange,
onPaginationChange,
onFiltersChange,
manualFiltering,
sorting,
pagination,
filters,
toolbarLeftComponent,
toolbarRightComponent,
toolbarRightActionsComponent,
isLoading,
loadingComponent,
children,
}: TableProps) {
const { defaultPreviewSizePercent } = useLayout();
// If children are provided, use them (for custom content)
// Otherwise, render the StudyList.Table with the provided props
const content = children ? (
children
) : (
<div className="flex h-full min-h-0 w-full flex-col px-3 pb-3 pt-0">
<div className="min-h-0 flex-1">
<div className="h-full rounded-md px-2 pb-2 pt-0">
<StudyListTable
columns={columns}
data={data}
title={title}
initialVisibility={initialVisibility}
enforceSingleSelection={enforceSingleSelection}
showColumnVisibility={showColumnVisibility}
tableClassName={tableClassName}
onSelectionChange={onSelectionChange}
toolbarLeftComponent={toolbarLeftComponent}
toolbarRightComponent={toolbarRightComponent}
toolbarRightActionsComponent={toolbarRightActionsComponent}
onSortingChange={onSortingChange}
onPaginationChange={onPaginationChange}
onFiltersChange={onFiltersChange}
manualFiltering={manualFiltering}
sorting={sorting}
pagination={pagination}
filters={filters}
isLoading={isLoading}
loadingComponent={loadingComponent}
/>
</div>
</div>
</div>
);
return <ResizablePanel defaultSize={100 - defaultPreviewSizePercent}>{content}</ResizablePanel>;
}
function Preview({
minSizePercent,
children,
}: {
minSizePercent?: number;
children?: React.ReactNode;
}) {
const { isPreviewOpen, defaultPreviewSizePercent, minPreviewSizePercent } = useLayout();
if (!isPreviewOpen) {
return null;
}
return (
<>
<ResizableHandle />
<ResizablePanel
defaultSize={defaultPreviewSizePercent}
minSize={minSizePercent ?? minPreviewSizePercent}
>
{children}
</ResizablePanel>
</>
);
}
LayoutRoot.displayName = 'Layout';
// Export for backward compatibility (will be removed from StudyList namespace)
export const Layout = Object.assign(LayoutRoot, {
Table,
Preview,
});
@@ -0,0 +1,45 @@
import * as React from 'react';
import { ScrollArea } from '../../ScrollArea';
import { PreviewContent } from './PreviewContent';
import { PreviewHeader } from './PreviewHeader';
type PreviewContainerProps = {
children: React.ReactNode;
};
function PreviewContainerRoot({ children }: PreviewContainerProps) {
let header: React.ReactNode = null;
let content: React.ReactNode = null;
React.Children.forEach(children, child => {
if (React.isValidElement(child)) {
const childType = child.type;
if (childType === PreviewHeader) {
header = child;
} else if (childType === PreviewContent) {
if (content) {
throw new Error('PreviewContainer can only contain one PreviewContent.');
}
content = child;
} else {
throw new Error('PreviewContainer can only contain PreviewHeader and PreviewContent.');
}
}
});
if (!content) {
throw new Error('PreviewContainer must contain PreviewContent.');
}
return (
<div className="bg-background relative flex h-full w-full flex-col">
{header}
<div className="direction-y flex min-h-0 flex-1 pr-3 pb-3 pt-[15px]">{content}</div>
</div>
);
}
PreviewContainerRoot.displayName = 'PreviewContainer';
export const PreviewContainer = PreviewContainerRoot;
@@ -0,0 +1,186 @@
import * as React from 'react';
import { Thumbnail } from '../../Thumbnail';
import { TooltipProvider } from '../../Tooltip';
import {
PreviewThumbnailStatusState,
type PreviewThumbnailStatus,
type StudyRow,
} from '../types/types';
import { PreviewPatientSummary } from './PreviewPatientSummary';
import { PreviewSeriesList } from './PreviewSeriesList';
import { ToggleGroup, ToggleGroupItem } from '../../ToggleGroup';
import { Icons } from '../../Icons';
import { ScrollArea } from '../../ScrollArea';
type PreviewSeriesViewMode = 'thumbnails' | 'list';
type PreviewSeriesView = 'all' | PreviewSeriesViewMode;
function PreviewContent({
study,
series = [],
seriesView = 'all',
onThumbnailImageError,
}: {
study?: StudyRow | null;
series?: Array<{
seriesInstanceUid?: string;
SeriesInstanceUID?: string;
description?: string;
SeriesDescription?: string;
seriesNumber?: number;
SeriesNumber?: number;
numInstances?: number;
numSeriesInstances?: number;
modality?: string;
Modality?: string;
thumbnailStatus?: PreviewThumbnailStatus;
}>;
/**
* Controls which series views are available in the preview.
* - `'all'` (default): toggle visible; initially shows the thumbnails.
* - `'thumbnails'`: toggle hidden; locked to thumbnails view.
* - `'list'`: toggle hidden; locked to list view.
*/
seriesView?: PreviewSeriesView;
/**
* Called when the thumbnail src URL fails to decode in the browser (broken image).
*/
onThumbnailImageError?: (seriesInstanceUid: string) => void;
}) {
const isToggleVisible = seriesView === 'all';
const [seriesViewMode, setSeriesViewMode] = React.useState<PreviewSeriesViewMode>('thumbnails');
const effectiveSeriesViewMode: PreviewSeriesViewMode = isToggleVisible
? seriesViewMode
: seriesView;
const imagingSeries = series.filter(
s => s.thumbnailStatus?.status !== PreviewThumbnailStatusState.NotApplicable
);
const nonImagingSeries = series.filter(
s => s.thumbnailStatus?.status === PreviewThumbnailStatusState.NotApplicable
);
// Handle empty state when no study is provided
if (!study) {
return (
<PreviewPatientSummary>
<PreviewPatientSummary.Patient />
<PreviewPatientSummary.Workflows />
</PreviewPatientSummary>
);
}
return (
<TooltipProvider delayDuration={200}>
<div className="flex h-full min-h-0 w-full flex-col gap-3">
<PreviewPatientSummary data={study}>
<PreviewPatientSummary.Patient />
<PreviewPatientSummary.Workflows />
</PreviewPatientSummary>
<div className="text-muted-foreground flex h-5 w-full items-center justify-between gap-1 px-2 text-base">
<span className="leading-tight">
{series?.length ? study?.description || 'No Description' : 'No Series'}
</span>
{isToggleVisible && (
<ToggleGroup
type="single"
value={seriesViewMode}
onValueChange={value => value && setSeriesViewMode(value as 'thumbnails' | 'list')}
>
<ToggleGroupItem
value="thumbnails"
aria-label="Thumbnail view"
className="text-primary"
>
<Icons.ThumbnailView />
</ToggleGroupItem>
<ToggleGroupItem
value="list"
aria-label="List view"
className="text-primary"
>
<Icons.ListView />
</ToggleGroupItem>
</ToggleGroup>
)}
</div>
<ScrollArea className="h-full min-h-0 w-full flex-1">
{effectiveSeriesViewMode === 'thumbnails' ? (
<div className="flex flex-col">
{imagingSeries.length > 0 && (
<div className="grid grid-cols-[repeat(auto-fit,_minmax(0,135px))] place-items-start gap-[4px] pr-2">
{imagingSeries.map((seriesItem, index) => {
const seriesUID =
seriesItem.seriesInstanceUid || seriesItem.SeriesInstanceUID || String(index);
const thumbnailState = seriesItem.thumbnailStatus;
const imageSrc =
thumbnailState?.status === PreviewThumbnailStatusState.Ready
? thumbnailState.src
: undefined;
return (
<Thumbnail
key={`series-imaging-${seriesUID}`}
displaySetInstanceUID={`series-${seriesUID}`}
imageSrc={imageSrc as any}
onImageLoadError={() => onThumbnailImageError?.(seriesUID)}
imageAltText={seriesItem.description || seriesItem.SeriesDescription || ''}
description={
seriesItem.description || seriesItem.SeriesDescription || '(empty)'
}
seriesNumber={seriesItem.seriesNumber ?? seriesItem.SeriesNumber ?? ''}
numInstances={seriesItem.numSeriesInstances ?? seriesItem.numInstances ?? 0}
modality={seriesItem.modality || seriesItem.Modality || ''}
isActive={false}
onClick={() => {}}
onDoubleClick={() => {}}
isDraggable={false}
viewPreset="thumbnails"
thumbnailType="thumbnail"
>
{thumbnailState?.status === PreviewThumbnailStatusState.NotAvailable && (
<div className="bg-muted/50 flex h-full w-full items-center justify-center rounded">
<Icons.SeriesPlaceholder className="text-muted-foreground h-[40px] w-[60px] opacity-50" />
</div>
)}
</Thumbnail>
);
})}
</div>
)}
{nonImagingSeries.length > 0 && (
<div className="mt-1 grid grid-cols-[repeat(auto-fit,_minmax(0,275px))] place-items-start gap-[2px] pr-2">
{nonImagingSeries.map((seriesItem, index) => {
const seriesUID =
seriesItem.seriesInstanceUid || seriesItem.SeriesInstanceUID || String(index);
return (
<Thumbnail
key={`series-non-imaging-${seriesUID}`}
displaySetInstanceUID={`series-${seriesUID}`}
imageAltText={seriesItem.description || seriesItem.SeriesDescription || ''}
description={
seriesItem.description || seriesItem.SeriesDescription || '(empty)'
}
seriesNumber={seriesItem.seriesNumber ?? seriesItem.SeriesNumber ?? ''}
numInstances={seriesItem.numSeriesInstances ?? seriesItem.numInstances ?? 0}
modality={seriesItem.modality || seriesItem.Modality || ''}
isActive={false}
onClick={() => {}}
onDoubleClick={() => {}}
isDraggable={false}
viewPreset="list"
thumbnailType="thumbnailNoImage"
/>
);
})}
</div>
)}
</div>
) : (
<PreviewSeriesList series={series} />
)}
</ScrollArea>
</div>
</TooltipProvider>
);
}
export { PreviewContent };
@@ -0,0 +1,5 @@
import * as React from 'react';
export function PreviewHeader({ children }: { children?: React.ReactNode }) {
return <div className="z-10 mt-5 mr-5 flex items-center justify-end gap-1">{children}</div>;
}
@@ -0,0 +1,235 @@
/**
* PreviewPatientSummary — compound component for patient header + workflow actions
*
* What it is
* - A compact summary API composed under a root provider: Patient and Workflows.
* - Subcomponents read the current study row from context.
*
* Minimal usage
* <PreviewPatientSummary data={row}>
* <PreviewPatientSummary.Patient />
* <PreviewPatientSummary.Workflows />
* </PreviewPatientSummary>
*
* Default workflow behavior
* - Workflows uses WorkflowsProvider context for available workflows and the default selection.
* - Shows the default workflow as a badge (with a clear-X) and the remaining applicable
* workflows as buttons below.
*
* Helpful references
* - platform/ui-next/src/components/StudyList/components/PreviewContent.tsx (in-context example, including empty state handling)
*/
import * as React from 'react';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cn } from '../../../lib/utils';
import { Icons } from '../../Icons/Icons';
import { Button } from '../../Button';
import type { StudyRow } from '../types/types';
import { useWorkflows } from './WorkflowsProvider';
type SummaryContextValue = {
data: StudyRow | null;
};
const SummaryContext = React.createContext<SummaryContextValue | null>(null);
function useSummaryContext(): SummaryContextValue {
const context = React.useContext(SummaryContext);
if (!context) {
throw new Error(
'PreviewPatientSummary.* components must be used within <PreviewPatientSummary>'
);
}
return context;
}
type RootProps = {
data?: StudyRow | null;
className?: string;
children?: React.ReactNode;
};
function Root({ data: dataProp, className, children }: RootProps) {
const data = dataProp ?? null;
const value = React.useMemo<SummaryContextValue>(() => ({ data }), [data]);
return (
<SummaryContext.Provider value={value}>
<div className={cn('flex w-full flex-col gap-1', className)}>{children}</div>
</SummaryContext.Provider>
);
}
function Patient({ className }: { className?: string } = {}) {
const { data } = useSummaryContext();
const patientName = data?.patientName;
const mrn = data?.mrn;
const nameContent = patientName ?? 'Select a study';
const nameTitle =
typeof patientName === 'string' || typeof patientName === 'number'
? String(patientName)
: undefined;
const showMrn = mrn !== null && mrn !== undefined && mrn !== '';
const mrnTitle =
typeof mrn === 'string' || typeof mrn === 'number' ? String(mrn) : undefined;
return (
<div className={cn('bg-muted flex items-center gap-3 rounded-lg px-4 py-3', className)}>
<div
className="text-primary shrink-0"
aria-hidden
style={{ width: 33, height: 33 }}
>
<Icons.PatientStudyList />
</div>
<div className="flex h-[38px] min-w-0 flex-col justify-center gap-px">
<span
className="text-foreground truncate text-lg font-medium leading-tight"
title={nameTitle}
>
{nameContent}
</span>
{showMrn && (
<span
className="text-muted-foreground truncate text-lg leading-tight"
title={mrnTitle}
>
{mrn}
</span>
)}
</div>
</div>
);
}
/**
* Workflows panel.
*
* - Must be used inside `<PreviewPatientSummary>` to read the current study from context.
* - Must be used inside `<WorkflowsProvider>` to access workflow context.
* - Shows the default workflow badge (with a clear-X) when one is set, and renders the
* remaining applicable workflows as buttons below.
*/
function Workflows({ className }: { className?: string } = {}) {
const { data: studyRow } = useSummaryContext();
const { workflows, getWorkflowsForStudy, getDefaultWorkflowForStudy, setDefaultWorkflowId } =
useWorkflows();
const availableWorkflows = React.useMemo(() => {
if (studyRow) {
return getWorkflowsForStudy(studyRow);
}
return workflows;
}, [studyRow, workflows, getWorkflowsForStudy]);
const defaultWorkflow = React.useMemo(() => {
if (studyRow) {
return getDefaultWorkflowForStudy(studyRow);
}
return workflows.find(w => w.isDefault);
}, [studyRow, workflows, getDefaultWorkflowForStudy]);
const otherWorkflows = React.useMemo(() => {
if (!defaultWorkflow) {
return availableWorkflows;
}
return availableWorkflows.filter(w => w.displayName !== defaultWorkflow.displayName);
}, [availableWorkflows, defaultWorkflow]);
const handleLaunch = (displayName: string) => {
if (!studyRow) {
return;
}
const wf = availableWorkflows.find(w => w.displayName === displayName);
wf?.launchWithStudy(studyRow);
};
const handleClearDefault = () => {
setDefaultWorkflowId();
};
return (
<div
className={cn(
'border-border/50 bg-muted w-full rounded-lg px-4 py-3 text-left transition',
className
)}
>
<div className="flex w-full items-center justify-between">
<span className="text-foreground text-base font-medium leading-tight">Launch workflow</span>
<span
className="text-primary shrink-0 -translate-y-0.5"
aria-hidden
style={{ width: 18, height: 18 }}
>
<Icons.Info />
</span>
</div>
{defaultWorkflow && (
<div
className="mt-2 flex flex-wrap items-center gap-0"
role="status"
aria-live="polite"
>
<Button
variant="ghost"
size="sm"
className="bg-primary/20 text-primary ring-primary ring-offset-background ml-1 mb-1 h-6 w-32 overflow-hidden ring-1 ring-offset-2"
onClick={() => handleLaunch(defaultWorkflow.displayName)}
title={defaultWorkflow.displayName}
>
<span className="truncate">{defaultWorkflow.displayName}</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear default mode"
className="text-primary focus-visible:ring-ring focus-visible:ring-offset-background ml-1.5 mb-1 opacity-70 transition hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2"
onClick={handleClearDefault}
>
<Cross2Icon
className="text-primary h-3.5 w-3.5"
aria-hidden
/>
<span className="sr-only">Clear default mode</span>
</Button>
</div>
)}
{defaultWorkflow && studyRow && otherWorkflows.length > 0 && (
<div className="text-muted-foreground mt-2 text-sm">Other Available Workflows</div>
)}
{studyRow && otherWorkflows.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-0">
{otherWorkflows.map(wf => (
<Button
key={wf.displayName}
variant="ghost"
size="sm"
className="bg-primary/20 ml-1 mb-1 h-6 w-32 overflow-hidden"
onClick={() => handleLaunch(wf.displayName)}
title={wf.displayName}
>
<span className="truncate">{wf.displayName}</span>
</Button>
))}
</div>
)}
</div>
);
}
type PreviewPatientSummaryNamespace = typeof Root & {
Patient: typeof Patient;
Workflows: typeof Workflows;
};
export const PreviewPatientSummary: PreviewPatientSummaryNamespace = Object.assign(Root, {
Patient,
Workflows,
});
@@ -0,0 +1,64 @@
import * as React from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../Table';
import { Icons } from '../../Icons';
type Series = {
seriesInstanceUid?: string;
SeriesInstanceUID?: string;
modality?: string;
Modality?: string;
description?: string;
SeriesDescription?: string;
seriesNumber?: number | string;
SeriesNumber?: number | string;
numSeriesInstances?: number;
numInstances?: number;
};
type PreviewSeriesListProps = {
series: Series[];
onSeriesClick?: (series: Series) => void;
};
export function PreviewSeriesList({ series, onSeriesClick }: PreviewSeriesListProps) {
return (
<div className="w-full px-2">
<Table noScroll>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="pl-0 text-base font-normal">
<span className="text-foreground">Modality</span>
<span className="text-muted-foreground"> / Series</span>
</TableHead>
<TableHead className="text-foreground w-8 pr-0 text-right text-base font-normal">
<Icons.Series className="ml-auto h-4 w-4" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{series.map((s, idx) => {
const seriesUID = s.seriesInstanceUid || s.SeriesInstanceUID || String(idx);
const modality = String(s.modality || s.Modality || '').toUpperCase();
const description = s.description || s.SeriesDescription || '';
const numInstances = s.numSeriesInstances ?? s.numInstances ?? 0;
return (
<TableRow
key={seriesUID}
className="hover:text-muted-foreground cursor-default hover:bg-transparent"
>
<TableCell className="pl-0 text-base">
<div className="flex items-center gap-2">
<span className="text-foreground font-normal">{modality}</span>
<span className="font-normal">{description}</span>
</div>
</TableCell>
<TableCell className="w-8 pr-0 text-right text-base">{numInstances}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,69 @@
import * as React from 'react';
import { Button } from '../../Button';
import { Icons } from '../../Icons';
import { useLayout } from './Layout';
type PreviewToggleButtonProps = React.HTMLAttributes<HTMLButtonElement> & {
'aria-label'?: string;
shouldShow: boolean;
onClick: () => void;
defaultAriaLabel: string;
};
function PreviewToggleButton({
className,
'aria-label': ariaLabel,
shouldShow,
onClick,
defaultAriaLabel,
}: PreviewToggleButtonProps) {
if (!shouldShow) {
return null;
}
return (
<Button
variant="ghost"
size="icon"
aria-label={ariaLabel ?? defaultAriaLabel}
onClick={onClick}
className={className}
>
<Icons.PanelRight
aria-hidden="true"
className="h-4 w-4"
/>
</Button>
);
}
export function OpenPreviewButton({
className,
'aria-label': ariaLabel = 'Open preview',
}: React.HTMLAttributes<HTMLButtonElement> & { 'aria-label'?: string }) {
const { isPreviewOpen, openPreview } = useLayout();
return (
<PreviewToggleButton
className={className}
aria-label={ariaLabel}
shouldShow={!isPreviewOpen}
onClick={openPreview}
defaultAriaLabel="Open preview"
/>
);
}
export function ClosePreviewButton({
className,
'aria-label': ariaLabel = 'Close preview',
}: React.HTMLAttributes<HTMLButtonElement> & { 'aria-label'?: string }) {
const { isPreviewOpen, closePreview } = useLayout();
return (
<PreviewToggleButton
className={className}
aria-label={ariaLabel}
shouldShow={isPreviewOpen}
onClick={closePreview}
defaultAriaLabel="Close preview"
/>
);
}
@@ -0,0 +1,214 @@
import * as React from 'react';
import { Popover, PopoverTrigger, PopoverContent } from '../../Popover/Popover';
import {
Select,
SelectContent,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '../../Select';
import { Label } from '../../Label';
import { Button } from '../../Button';
import { useWorkflows } from './WorkflowsProvider';
/** Context to allow subcomponents to close the popover */
type SettingsPopoverContextValue = {
close: () => void;
};
const SettingsPopoverContext = React.createContext<SettingsPopoverContextValue | null>(null);
function useSettingsPopoverContext() {
const ctx = React.useContext(SettingsPopoverContext);
if (!ctx) {
throw new Error('SettingsPopover subcomponents must be used within <SettingsPopover>');
}
return ctx;
}
type SettingsPopoverProps = {
children?: React.ReactNode;
};
/** Marker subcomponent: consumed by the root to render the PopoverTrigger */
type TriggerProps = {
children: React.ReactNode;
};
function SettingsPopoverTrigger(_props: TriggerProps) {
// This is a marker-only component. The root extracts its children and renders them inside PopoverTrigger.
return null;
}
SettingsPopoverTrigger.displayName = 'SettingsPopover.Trigger';
/**
* Root SettingsPopover component (compound API).
* Usage:
* <SettingsPopover>
* <SettingsPopover.Trigger><Button>...</Button></SettingsPopover.Trigger>
* <SettingsPopover.Content>
* <SettingsPopover.Workflow ... />
* <SettingsPopover.Divider />
* <SettingsPopover.Item onClick={...}>About</SettingsPopover.Item>
* </SettingsPopover.Content>
* </SettingsPopover>
*/
function SettingsPopoverRoot({ children }: SettingsPopoverProps) {
const [open, setOpen] = React.useState(false);
const close = React.useCallback(() => setOpen(false), []);
// Extract the Trigger and Content nodes from children
const childrenArray = React.Children.toArray(children);
let triggerNode: React.ReactNode | null = null;
let contentNode: React.ReactElement<ContentProps> | null = null;
for (const child of childrenArray) {
if (React.isValidElement(child) && child.type === SettingsPopoverTrigger) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
triggerNode = (child.props as TriggerProps).children;
} else if (React.isValidElement(child) && child.type === SettingsPopoverContent) {
contentNode = child as React.ReactElement<ContentProps>;
}
}
if (!triggerNode) {
throw new Error(
'<SettingsPopover.Trigger> is required as a direct child of <SettingsPopover>.'
);
}
if (!contentNode) {
throw new Error(
'<SettingsPopover.Content> is required as a direct child of <SettingsPopover>.'
);
}
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>{triggerNode}</PopoverTrigger>
<SettingsPopoverContext.Provider value={{ close }}>
{contentNode}
</SettingsPopoverContext.Provider>
</Popover>
);
}
type ContentProps = {
children?: React.ReactNode;
};
function SettingsPopoverContent({ children }: ContentProps) {
return (
<PopoverContent
align="end"
sideOffset={8}
className="w-[315px] p-4"
onOpenAutoFocus={e => e.preventDefault()}
>
{children}
</PopoverContent>
);
}
/**
* SettingsPopover.Workflow
* Renders the "Default Workflow" row with a Select.
* Closes the popover after selection.
*/
function Workflow() {
const { close } = useSettingsPopoverContext();
const { workflows, defaultWorkflowId, setDefaultWorkflowId } = useWorkflows();
const selectId = React.useId();
const NO_DEFAULT_VALUE = '__NO_DEFAULT__';
return (
<div className="mt-0 flex items-center gap-3">
<Label
htmlFor={selectId}
className="whitespace-nowrap"
>
Default Workflow
</Label>
<div className="min-w-0 flex-1">
<Select
value={defaultWorkflowId ?? undefined}
onValueChange={value => {
if (value === NO_DEFAULT_VALUE) {
setDefaultWorkflowId();
} else {
setDefaultWorkflowId(value);
}
close();
}}
>
<SelectTrigger
id={selectId}
className="w-full"
>
<SelectValue placeholder="Select Workflow" />
</SelectTrigger>
{/* Keep stopPropagation so the Select's portal doesn't trigger outside interactions on the Popover */}
<SelectContent onPointerDown={e => e.stopPropagation()}>
{workflows.map(workflow => (
<SelectItem
key={workflow.id}
value={workflow.id}
>
{workflow.displayName}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={NO_DEFAULT_VALUE}>No Default</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
/**
* SettingsPopover.Divider
* A simple divider to separate sections inside the popover.
*/
function Divider() {
return <div className="bg-muted -mx-2 my-3 h-px" />;
}
type ItemProps = {
children: React.ReactNode;
onClick?: () => void;
};
function Item({ children, onClick }: ItemProps) {
const { close } = useSettingsPopoverContext();
const handleClick = () => {
onClick?.();
close();
};
return (
<Button
variant="ghost"
size="default"
onClick={handleClick}
className="h-7 w-full justify-start"
>
{children}
</Button>
);
}
SettingsPopoverRoot.displayName = 'SettingsPopover';
export const SettingsPopover = Object.assign(SettingsPopoverRoot, {
Trigger: SettingsPopoverTrigger,
Content: SettingsPopoverContent,
Workflow,
Divider,
Item,
});
@@ -0,0 +1,229 @@
import React, { type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { DataTable, useDataTable } from '../../DataTable';
import type { DataTableProps } from '../../DataTable/DataTable';
import { Button } from '../../Button';
import { DatePickerWithRange } from '../../DateRange';
import { InputMultiSelect } from '../../InputMultiSelect';
import type { StudyDateRangeFilter, StudyRow } from '../types/types';
import { tokenizeModalities } from '../utils/tokenizeModalities';
import { useWorkflows } from './WorkflowsProvider';
import { COLUMN_IDS } from '../columns/defaultColumns';
export type TableProps = Omit<DataTableProps<StudyRow>, 'children' | 'getRowId'> & {
title?: ReactNode;
showColumnVisibility?: boolean;
tableClassName?: string;
toolbarLeftComponent?: ReactNode;
toolbarRightActionsComponent?: ReactNode;
toolbarRightComponent?: ReactNode;
isLoading?: boolean;
loadingComponent?: ReactNode;
};
export function Table({
columns,
data,
title,
initialVisibility = {},
sorting,
pagination,
filters,
onSortingChange,
onPaginationChange,
onFiltersChange,
enforceSingleSelection = true,
showColumnVisibility = true,
tableClassName,
onSelectionChange,
toolbarLeftComponent,
toolbarRightActionsComponent,
toolbarRightComponent,
isLoading,
loadingComponent,
manualFiltering,
}: TableProps) {
return (
<DataTable<StudyRow>
data={data}
columns={columns}
getRowId={row => row.studyInstanceUid}
initialVisibility={initialVisibility}
sorting={sorting}
pagination={pagination}
filters={filters}
onSortingChange={onSortingChange}
onPaginationChange={onPaginationChange}
onFiltersChange={onFiltersChange}
manualFiltering={manualFiltering}
enforceSingleSelection={enforceSingleSelection}
onSelectionChange={onSelectionChange}
>
<TableContent
title={title}
showColumnVisibility={showColumnVisibility}
tableClassName={tableClassName}
toolbarLeftComponent={toolbarLeftComponent}
toolbarRightActionsComponent={toolbarRightActionsComponent}
toolbarRightComponent={toolbarRightComponent}
isLoading={isLoading}
loadingComponent={loadingComponent}
/>
</DataTable>
);
}
function TableContent({
title,
showColumnVisibility,
tableClassName,
toolbarLeftComponent,
toolbarRightActionsComponent,
toolbarRightComponent,
isLoading,
loadingComponent,
}: {
title?: ReactNode;
showColumnVisibility?: boolean;
tableClassName?: string;
toolbarLeftComponent?: ReactNode;
toolbarRightActionsComponent?: ReactNode;
toolbarRightComponent?: ReactNode;
isLoading?: boolean;
loadingComponent?: ReactNode;
}) {
const { t } = useTranslation('StudyList');
const { table } = useDataTable<StudyRow>();
const modalityOptions = useMemo(() => {
const rows = (table.options?.data as StudyRow[]) ?? [];
// Build a flat list of modality tokens across all rows.
// tokenizeModalities uppercases and splits on whitespace/slash/comma to produce unique modality codes for filtering.
const tokens = rows.flatMap(r => tokenizeModalities(String(r.modalities ?? '')));
return Array.from(new Set(tokens)).sort();
}, [table.options?.data]);
// Access workflow provider for default workflow + launch
const { getDefaultWorkflowForStudy } = useWorkflows();
return (
<div className="flex h-full flex-col">
{(showColumnVisibility || title) && (
<DataTable.Toolbar>
<div className="absolute left-0">{toolbarLeftComponent}</div>
{title ? <DataTable.Title>{title}</DataTable.Title> : null}
<div className="absolute right-0 flex items-center">
{toolbarRightActionsComponent}
{toolbarRightActionsComponent && <div className="bg-input mx-2 h-4 w-px" />}
{/* Pagination appears to the left of the "View" button */}
<DataTable.Pagination<StudyRow> />
{showColumnVisibility && <DataTable.ViewOptions<StudyRow> />}
{toolbarRightComponent}
</div>
</DataTable.Toolbar>
)}
<DataTable.Table<StudyRow> tableClassName={tableClassName}>
<DataTable.Header<StudyRow> />
<DataTable.FilterRow<StudyRow>
excludeColumnIds={[COLUMN_IDS.INSTANCES]}
renderFilterCell={({ columnId, value, setValue }) => {
if (columnId === COLUMN_IDS.ACTIONS) {
return (
<div className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => table.setColumnFilters([])}
aria-label="Reset filters"
>
Reset
</Button>
</div>
);
}
if (columnId === COLUMN_IDS.STUDY_DATE_TIME) {
const dateRange =
value && typeof value === 'object'
? (value as StudyDateRangeFilter)
: {};
const startDate = dateRange.startDate ?? '';
const endDate = dateRange.endDate ?? '';
return (
<DatePickerWithRange
id={COLUMN_IDS.STUDY_DATE_TIME}
startDate={startDate}
endDate={endDate}
onChange={next => {
const normalized = {
...(next.startDate ? { startDate: next.startDate } : {}),
...(next.endDate ? { endDate: next.endDate } : {}),
};
setValue(Object.keys(normalized).length > 0 ? normalized : undefined);
}}
/>
);
}
if (columnId === COLUMN_IDS.MODALITIES) {
const selected = Array.isArray(value) ? (value as string[]) : [];
return (
<InputMultiSelect
options={modalityOptions}
value={selected}
onChange={next => setValue(next)}
>
<InputMultiSelect.Field>
<InputMultiSelect.Summary />
<InputMultiSelect.Input
ariaLabel="Filter Modalities"
placeholder=""
/>
</InputMultiSelect.Field>
<InputMultiSelect.Content
fitToContent
maxWidth={185}
>
<InputMultiSelect.Options />
</InputMultiSelect.Content>
</InputMultiSelect>
);
}
// Return null/undefined to use default rendering for other columns
return null;
}}
/>
<DataTable.Body<StudyRow>
emptyMessage={t('No studies available')}
isLoading={isLoading}
loadingComponent={loadingComponent}
rowProps={{
className: 'group cursor-pointer',
onClick: row => {
const original = row.original as StudyRow;
const defaultWorkflow = getDefaultWorkflowForStudy(original);
// When a default workflow is set, do not allow a second click to unselect.
// Always select on click; otherwise toggle selection.
if (defaultWorkflow) {
if (!row.getIsSelected()) {
row.toggleSelected(true);
}
} else {
row.toggleSelected();
}
},
onDoubleClick: row => {
const original = row.original as StudyRow;
const defaultWorkflow = getDefaultWorkflowForStudy(original);
if (!defaultWorkflow) {
return;
}
// Ensure the row is selected, then launch with the default workflow
if (!row.getIsSelected()) {
row.toggleSelected(true);
}
defaultWorkflow.launchWithStudy(original);
},
}}
/>
</DataTable.Table>
</div>
);
}
@@ -0,0 +1,72 @@
import * as React from 'react';
import { Button } from '../../Button';
import { Icons } from '../../Icons';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '../../DropdownMenu';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../../Tooltip';
import { useWorkflows } from './WorkflowsProvider';
import type { StudyRow } from '../types/types';
type Props = {
studyRow: StudyRow;
align?: 'start' | 'end' | 'center';
};
export function WorkflowMenu({ studyRow, align = 'end' }: Props) {
const [open, setOpen] = React.useState(false);
const { getWorkflowsForStudy } = useWorkflows();
const workflows = getWorkflowsForStudy(studyRow);
return (
<DropdownMenu
open={open}
onOpenChange={setOpen}
>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
aria-expanded={open}
aria-haspopup="menu"
aria-label="Action Menu"
className="bg-primary/20 text-primary hover:bg-primary/30 mt-1 h-6 w-6 transition-opacity"
>
<Icons.More className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Action Menu</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent
align={align}
onClick={e => e.stopPropagation()}
>
<div className="text-muted-foreground border-input my-1.5 mx-1 border-b py-1 pl-1 pr-4 text-sm">
Launch Workflow:
</div>
{workflows.map(workflow => (
<DropdownMenuItem
key={workflow.id}
onSelect={e => {
e.preventDefault();
workflow.launchWithStudy(studyRow);
}}
className={workflow.isDefault ? 'font-semibold' : undefined}
aria-current={workflow.isDefault ? 'true' : undefined}
>
{workflow.isDefault ? '✓ ' : null}
{workflow.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,199 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import type { StudyRow } from '../types/types';
import { useDefaultWorkflow } from '../hooks/useDefaultWorkflow';
/**
* Represents a workflow that can be launched from the study list.
* Each workflow corresponds to a mode from appConfig.loadedModes.
*/
export interface Workflow {
/** Unique identifier for the workflow (from mode.id) */
readonly id: string;
/** Display name of the workflow (from mode.displayName) */
readonly displayName: string;
/** Launches the study using this workflow's route */
launchWithStudy: (studyRow: StudyRow) => void;
/** Determines if this workflow is applicable to the given study */
isApplicableToStudy: (studyRow: StudyRow) => boolean;
/** Whether this workflow is the default workflow */
readonly isDefault: boolean;
}
export type WorkflowsContextValue = {
/** All available workflows */
workflows: readonly Workflow[];
/** Get workflows that are applicable to a specific study */
getWorkflowsForStudy: (studyRow: StudyRow) => Workflow[];
/** Get the default workflow for a study (only if it's applicable) */
getDefaultWorkflowForStudy: (studyRow: StudyRow) => Workflow | undefined;
/** The current default workflow mode ID */
defaultWorkflowId: string | undefined;
/** Set the default workflow ID */
setDefaultWorkflowId: (workflowId?: string) => void;
};
const WorkflowsContext = React.createContext<WorkflowsContextValue | undefined>(undefined);
export type Mode = {
id: string;
routeName: string;
displayName: string;
hide?: boolean;
isValidMode?: (args: { modalities: string; study: unknown }) => {
valid: boolean | null;
description?: string;
};
};
type WorkflowsProviderProps = {
/** Array of loaded modes from appConfig */
loadedModes: Mode[];
/** Optional data path prefix for routes (e.g., '/dicomweb') */
dataPath?: string;
/** Function to preserve query parameters when launching workflows */
preserveQueryParameters: (query: URLSearchParams) => void;
children: React.ReactNode;
};
/**
* Provider that creates workflows from loaded modes and provides them via context.
* Each workflow can launch studies and determine applicability based on mode validation.
*/
export function WorkflowsProvider({
loadedModes,
dataPath,
preserveQueryParameters,
children,
}: WorkflowsProviderProps) {
const navigate = useNavigate();
// Get valid workflow IDs from loaded modes (for validation)
const validWorkflowIds = React.useMemo(() => {
return loadedModes.filter(m => !m.hide && m.displayName).map(m => m.id);
}, [loadedModes]);
// Use localStorage-backed hook for persistence
const [storedDefaultWorkflowId, setStoredDefaultWorkflowId] =
useDefaultWorkflow(validWorkflowIds);
// Convert null to undefined for consistency with context type
const defaultWorkflowId = storedDefaultWorkflowId ?? undefined;
// Wrapper that persists to localStorage
const setDefaultWorkflowId = React.useCallback(
(workflowId?: string) => {
setStoredDefaultWorkflowId(workflowId ?? null);
},
[setStoredDefaultWorkflowId]
);
const workflows = React.useMemo(() => {
const workflowList: Workflow[] = [];
for (const mode of loadedModes) {
// Filter out hidden modes
if (mode.hide) {
continue;
}
// Skip modes without displayName
if (!mode.displayName) {
continue;
}
const isDefault = mode.id === defaultWorkflowId;
const workflow: Workflow = {
get id() {
return mode.id;
},
get displayName() {
return mode.displayName;
},
launchWithStudy: (studyRow: StudyRow) => {
if (!studyRow.studyInstanceUid) {
console.warn('Cannot launch workflow: study has no studyInstanceUid');
return;
}
const query = new URLSearchParams();
query.append('StudyInstanceUIDs', studyRow.studyInstanceUid);
preserveQueryParameters(query);
const route = `${mode.routeName}${dataPath || ''}?${query.toString()}`;
navigate(route);
},
isApplicableToStudy: (studyRow: StudyRow) => {
if (!mode.isValidMode) {
// If no validation function, assume applicable
return true;
}
const modalitiesToCheck = String(studyRow.modalities ?? '').replaceAll('/', '\\');
const result = mode.isValidMode({
modalities: modalitiesToCheck,
study: studyRow,
});
// Return true only if valid is explicitly true
// null means hide (not applicable), false means disabled but visible
return result.valid === true;
},
get isDefault() {
return isDefault;
},
};
workflowList.push(workflow);
}
return workflowList;
}, [loadedModes, defaultWorkflowId, dataPath, navigate, preserveQueryParameters]);
const getWorkflowsForStudy = React.useCallback(
(studyRow: StudyRow): Workflow[] => {
return workflows.filter(workflow => workflow.isApplicableToStudy(studyRow));
},
[workflows]
);
const getDefaultWorkflowForStudy = React.useCallback(
(studyRow: StudyRow): Workflow | undefined => {
const applicableWorkflows = getWorkflowsForStudy(studyRow);
return applicableWorkflows.find(workflow => workflow.isDefault);
},
[getWorkflowsForStudy]
);
const value: WorkflowsContextValue = React.useMemo(
() => ({
workflows,
getWorkflowsForStudy,
getDefaultWorkflowForStudy,
defaultWorkflowId,
setDefaultWorkflowId,
}),
[
workflows,
getWorkflowsForStudy,
getDefaultWorkflowForStudy,
defaultWorkflowId,
setDefaultWorkflowId,
]
);
return <WorkflowsContext.Provider value={value}>{children}</WorkflowsContext.Provider>;
}
/**
* Hook to access the study list workflow context.
* Must be used within a WorkflowsProvider.
*/
export function useWorkflows(): WorkflowsContextValue {
const ctx = React.useContext(WorkflowsContext);
if (!ctx) {
throw new Error('useWorkflows must be used within <WorkflowsProvider>');
}
return ctx;
}
@@ -0,0 +1,51 @@
import * as React from 'react';
/**
* Persist and retrieve a default workflow string from localStorage.
* If `allowed` is provided, the returned value is guaranteed to be from the allowed list (or null).
*/
export function useDefaultWorkflow(
allowed?: readonly string[]
): [string | null, (next: string | null) => void] {
const storageKey = 'studyList.defaultWorkflow';
const [value, setValue] = React.useState<string | null>(null);
React.useEffect(() => {
try {
if (typeof window !== 'undefined') {
const raw = window.localStorage.getItem(storageKey);
if (raw != null) {
if (!allowed || allowed.includes(raw)) {
setValue(raw);
} else {
setValue(null);
}
}
}
} catch {
// no-op
}
}, [allowed]);
const setAndPersist = React.useCallback(
(next: string | null) => {
setValue(next);
try {
if (typeof window !== 'undefined') {
if (next == null) {
window.localStorage.removeItem(storageKey);
} else {
if (!allowed || allowed.includes(next)) {
window.localStorage.setItem(storageKey, next);
}
}
}
} catch {
// no-op
}
},
[allowed]
);
return [value, setAndPersist] as const;
}
@@ -0,0 +1,50 @@
// Import components for StudyList namespace
import {
defaultColumns,
textColumn,
COLUMN_IDS,
FILTERABLE_COLUMN_IDS,
TEXT_FILTER_COLUMN_IDS,
} from './columns/defaultColumns';
import { SettingsPopover } from './components/SettingsPopover';
import { PreviewContainer } from './components/PreviewContainer';
import { PreviewHeader } from './components/PreviewHeader';
import { Layout } from './components/Layout';
import { OpenPreviewButton, ClosePreviewButton } from './components/PreviewToggleButton';
import { PreviewContent } from './components/PreviewContent';
import { WorkflowsProvider } from './components/WorkflowsProvider';
// Types
export * from './types/types';
// Column ID constants
export { COLUMN_IDS, FILTERABLE_COLUMN_IDS, TEXT_FILTER_COLUMN_IDS };
// StudyList compound component namespace
type StudyListNamespace = typeof Layout & {
Table: typeof Layout.Table;
Preview: typeof Layout.Preview;
SettingsPopover: typeof SettingsPopover;
PreviewContainer: typeof PreviewContainer;
PreviewHeader: typeof PreviewHeader;
PreviewContent: typeof PreviewContent;
WorkflowsProvider: typeof WorkflowsProvider;
OpenPreviewButton: typeof OpenPreviewButton;
ClosePreviewButton: typeof ClosePreviewButton;
defaultColumns: typeof defaultColumns;
textColumn: typeof textColumn;
};
export const StudyList: StudyListNamespace = Object.assign(Layout, {
Table: Layout.Table,
Preview: Layout.Preview,
SettingsPopover,
PreviewContainer,
PreviewHeader,
PreviewContent,
WorkflowsProvider,
OpenPreviewButton,
ClosePreviewButton,
defaultColumns,
textColumn,
});
@@ -0,0 +1,42 @@
export type StudyRow = {
studyInstanceUid: string;
patientName: string;
mrn: string;
/** Raw date string (YYYYMMDD or YYYY.MM.DD format) */
date: string;
/** Raw time string (HH, HHmm, HHmmss, or HHmmss.SSS format) */
time: string;
modalities: string;
description: string;
accession: string;
instances: number;
// A data source may map additional fields (e.g. extra DICOM attributes pulled
// in via `includefield`). Allowing arbitrary keys means a custom column can
// read one without StudyRow needing an edit per field. The declared fields
// above keep their precise types.
[key: string]: unknown;
};
export type StudyDateRangeFilter = {
startDate?: string;
endDate?: string;
};
export const PreviewThumbnailStatusState = {
Loading: 'loading',
Ready: 'ready',
NotAvailable: 'notAvailable',
NotApplicable: 'notApplicable',
} as const;
export type PreviewThumbnailStatusState =
(typeof PreviewThumbnailStatusState)[keyof typeof PreviewThumbnailStatusState];
type NonReadyPreviewThumbnailStatusState = Exclude<
PreviewThumbnailStatusState,
typeof PreviewThumbnailStatusState.Ready
>;
export type PreviewThumbnailStatus =
| { status: NonReadyPreviewThumbnailStatusState }
| { status: typeof PreviewThumbnailStatusState.Ready; src: string };
@@ -0,0 +1,6 @@
export function tokenizeModalities(value: string): string[] {
return String(value ?? '')
.toUpperCase()
.split(/[\s,\/\\]+/)
.filter(Boolean);
}
@@ -0,0 +1,119 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
type TableProps = React.HTMLAttributes<HTMLTableElement> & {
containerClassName?: string;
noScroll?: boolean;
};
const Table = React.forwardRef<HTMLTableElement, TableProps>(
({ className, containerClassName, noScroll = false, ...props }, ref) => (
<div className={cn('relative w-full', !noScroll && 'overflow-auto', containerClassName)}>
<table
ref={ref}
className={cn('caption-bottom text-muted-foreground w-full text-base', className)}
{...props}
/>
</div>
)
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn('border-input/50 [&_tr]:border-b', className)}
{...props}
/>
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn('bg-muted border-input/50 border-t [&>tr]:last:border-b-0', className)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'hover:bg-muted text-muted-foreground hover:text-highlight hover:[&>td]:text-highlight hover:[&>th]:text-highlight',
'data-[state=selected]:bg-popover data-[state=selected]:hover:bg-popover',
'data-[state=selected]:text-foreground data-[state=selected]:[&>td]:text-foreground data-[state=selected]:[&>th]:text-foreground',
'data-[state=selected]:hover:text-foreground data-[state=selected]:hover:[&>td]:text-foreground data-[state=selected]:hover:[&>th]:text-foreground',
'border-input/50 border-b transition-colors',
className
)}
{...props}
/>
)
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className
)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('text-muted-foreground mt-4 text-base', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
@@ -0,0 +1,11 @@
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from './Table';
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useDrag } from 'react-dnd';
@@ -6,12 +6,36 @@ import { Icons } from '../Icons';
import { DisplaySetMessageListTooltip } from '../DisplaySetMessageListTooltip';
import { TooltipTrigger, TooltipContent, Tooltip } from '../Tooltip';
const DraggableThumbnailContent = ({ dragData, children }: any) => {
const [, drag] = useDrag({
type: 'displayset',
item: { ...dragData },
canDrag: function () {
return Object.keys(dragData).length !== 0;
},
});
return (
<div
ref={drag}
className="h-full w-full"
>
{children}
</div>
);
};
const StaticThumbnailContent = ({ children }: any) => {
return <div className="h-full w-full">{children}</div>;
};
/**
* Display a thumbnail for a display set.
*/
const Thumbnail = ({
displaySetInstanceUID,
className,
children,
imageSrc,
imageAltText,
description,
@@ -30,22 +54,20 @@ const Thumbnail = ({
isTracked = false,
canReject = false,
dragData = {},
isDraggable = true,
onReject = () => {},
onClickUntrack = () => {},
ThumbnailMenuItems = () => {},
onImageLoadError = () => {},
}: 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 [imageLoadFailed, setImageLoadFailed] = useState(false);
useEffect(() => {
setImageLoadFailed(false);
}, [imageSrc]);
const shouldRenderThumbnailImage = Boolean(imageSrc && !imageLoadFailed);
const handleTouchEnd = e => {
const currentTime = new Date().getTime();
@@ -68,15 +90,19 @@ const Thumbnail = ({
>
<div className="h-[114px] w-[128px]">
<div className="relative bg-background">
{imageSrc ? (
{shouldRenderThumbnailImage ? (
<img
src={imageSrc}
alt={imageAltText}
className="h-[114px] w-[128px] rounded object-contain"
crossOrigin="anonymous"
onError={() => {
setImageLoadFailed(true);
onImageLoadError();
}}
/>
) : (
<div className="bg-background h-[114px] w-[128px] rounded"></div>
<div className="bg-background h-[114px] w-[128px] rounded">{children}</div>
)}
{/* bottom left */}
@@ -269,7 +295,9 @@ const Thumbnail = ({
<div
className={classnames(
className,
'bg-muted hover:bg-primary/30 group flex cursor-pointer select-none flex-col rounded outline-none',
'bg-muted group flex select-none flex-col rounded outline-none',
isDraggable && 'hover:bg-primary/30 cursor-pointer',
!isDraggable && 'cursor-default',
viewPreset === 'thumbnails' && 'h-[170px] w-[135px]',
viewPreset === 'list' && 'h-[40px] w-full'
)}
@@ -283,15 +311,19 @@ const Thumbnail = ({
onClick={onClick}
onDoubleClick={onDoubleClick}
onTouchEnd={handleTouchEnd}
role="button"
role={isDraggable ? 'button' : undefined}
>
<div
ref={drag}
className="h-full w-full"
>
{viewPreset === 'thumbnails' && renderThumbnailPreset()}
{viewPreset === 'list' && renderListPreset()}
</div>
{isDraggable ? (
<DraggableThumbnailContent dragData={dragData}>
{viewPreset === 'thumbnails' && renderThumbnailPreset()}
{viewPreset === 'list' && renderListPreset()}
</DraggableThumbnailContent>
) : (
<StaticThumbnailContent>
{viewPreset === 'thumbnails' && renderThumbnailPreset()}
{viewPreset === 'list' && renderListPreset()}
</StaticThumbnailContent>
)}
</div>
);
};
@@ -299,6 +331,7 @@ const Thumbnail = ({
Thumbnail.propTypes = {
displaySetInstanceUID: PropTypes.string.isRequired,
className: PropTypes.string,
children: PropTypes.node,
imageSrc: PropTypes.string,
/**
* Data the thumbnail should expose to a receiving drop target. Use a matching
@@ -326,7 +359,9 @@ Thumbnail.propTypes = {
isTracked: PropTypes.bool,
onClickUntrack: PropTypes.func,
countIcon: PropTypes.string,
isDraggable: PropTypes.bool,
thumbnailType: PropTypes.oneOf(['thumbnail', 'thumbnailTracked', 'thumbnailNoImage']),
onImageLoadError: PropTypes.func,
};
export { Thumbnail };
+28
View File
@@ -1,3 +1,4 @@
import { Badge, badgeVariants } from './Badge';
import { Button, buttonVariants } from './Button';
import {
SmartScrollbar,
@@ -47,6 +48,7 @@ import { Toggle, toggleVariants } from './Toggle';
import { ToggleGroup, ToggleGroupItem } from './ToggleGroup';
import { Input } from './Input';
import { InputNumber } from './InputNumber';
import { InputMultiSelect } from './InputMultiSelect';
import { Label } from './Label';
import { Switch } from './Switch';
import { Checkbox } from './Checkbox';
@@ -106,7 +108,18 @@ import { Toaster, toast } from './Sonner';
import { StudySummary } from './StudySummary';
import { ErrorBoundary } from './Errorboundary';
import { Header } from './Header';
import { HoverCard, HoverCardTrigger, HoverCardContent } from './HoverCard';
import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card';
import {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from './Table';
import {
ViewportActionButton,
PatientInfo,
@@ -129,6 +142,7 @@ import {
} from './ToolButton';
import { LayoutSelector } from './LayoutSelector';
import { ToolSettings } from './OHIFToolSettings';
export * from './StudyList';
export { DataRow } from './DataRow';
export { MeasurementTable } from './MeasurementTable';
export * from './ColorCircle';
@@ -144,6 +158,8 @@ export { useSegmentationTableContext, useSegmentationExpanded, useSegmentStatist
export {
Numeric,
ErrorBoundary,
Badge,
badgeVariants,
Button,
buttonVariants,
ThemeWrapper,
@@ -179,6 +195,7 @@ export {
DatePickerWithRange,
Input,
InputNumber,
InputMultiSelect,
Label,
Tabs,
TabsContent,
@@ -194,6 +211,14 @@ export {
ToggleGroup,
ToggleGroupItem,
ScrollBar,
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
Accordion,
AccordionContent,
AccordionItem,
@@ -239,6 +264,9 @@ export {
SegmentationTable,
StudySummary,
Header,
HoverCard,
HoverCardTrigger,
HoverCardContent,
Card,
CardHeader,
CardFooter,
+2 -2
View File
@@ -1,7 +1,7 @@
import * as utils from './utils';
import { cn, formatDICOMDate } from './utils';
import { cn, formatDICOMDate, formatDICOMTime, parseStudyDateTimestamp } from './utils';
export * from './components';
export * from './contextProviders';
export * as Types from './types';
export { utils, cn, formatDICOMDate };
export { utils, cn, formatDICOMDate, formatDICOMTime, parseStudyDateTimestamp };
export { useSessionStorage, useDynamicMaxHeight } from './hooks';
@@ -0,0 +1,61 @@
// Mock i18next so `t(key, fallback)` deterministically returns the fallback
// (the test env doesn't initialize i18n with resources).
jest.mock('i18next', () => ({
__esModule: true,
default: {
t: (_key: string, fallback?: string) => fallback,
language: 'en',
},
}));
import { formatDICOMDate } from './formatDICOMDate';
describe('formatDICOMDate', () => {
describe('valid DICOM dates', () => {
it('formats YYYYMMDD with the default fallback (MMM D, YYYY)', () => {
expect(formatDICOMDate('20180916')).toBe('Sep 16, 2018');
});
it('formats the YYYY.MM.DD variant', () => {
expect(formatDICOMDate('2018.09.16')).toBe('Sep 16, 2018');
});
it('honors fallbackFormat', () => {
expect(formatDICOMDate('20180916', { fallbackFormat: 'MMM-DD-YYYY' })).toBe('Sep-16-2018');
});
it('honors strFormat (overriding the locale key)', () => {
expect(formatDICOMDate('20180916', { strFormat: 'YYYY-MM-DD' })).toBe('2018-09-16');
});
});
describe('empty input', () => {
it('returns empty string by default', () => {
expect(formatDICOMDate('')).toBe('');
expect(formatDICOMDate(undefined as unknown as string)).toBe('');
});
it('returns invalidFallback when provided', () => {
expect(formatDICOMDate('', { invalidFallback: 'N/A' })).toBe('N/A');
});
});
describe('invalid input', () => {
it('preserves the prior lenient behavior when no invalidFallback is given', () => {
// A non-DICOM but moment-parseable string still formats via the lenient parse.
expect(formatDICOMDate('2018-09-16')).toBe('Sep 16, 2018');
// Genuinely unparseable input yields moment's "Invalid date".
expect(formatDICOMDate('garbage')).toBe('Invalid date');
});
it('returns invalidFallback for unparseable input when provided', () => {
expect(formatDICOMDate('garbage', { invalidFallback: 'N/A' })).toBe('N/A');
});
it('invalidFallback short-circuits the lenient parse', () => {
// Even though "2018-09-16" is loosely parseable, an explicit invalidFallback
// wins because strict DICOM parsing failed.
expect(formatDICOMDate('2018-09-16', { invalidFallback: 'N/A' })).toBe('N/A');
});
});
});
+48 -8
View File
@@ -1,24 +1,64 @@
import moment from 'moment';
import i18n from 'i18next';
export interface FormatDICOMDateOptions {
/**
* Explicit output format. When provided it overrides the locale's
* `Common:localDateFormat`; when omitted the locale key is used.
*/
strFormat?: string;
/**
* Output format used only when the active locale does not define
* `Common:localDateFormat` (8 of the shipped locales don't). Defaults to
* `MMM D, YYYY`.
*/
fallbackFormat?: string;
/**
* Returned when `date` is empty or cannot be parsed as a date at all. When
* omitted, the prior behavior is preserved: a lenient `moment(date).format(...)`
* is returned (which is `"Invalid date"` for unparseable input).
*/
invalidFallback?: string;
}
/**
* Formats DICOM date.
* Formats a DICOM date.
*
* @param {string} date
* @param {string} strFormat
* @returns {string} formatted date.
* @param date - Raw date string (e.g. `YYYYMMDD` or `YYYY.MM.DD`).
* @param options - See {@link FormatDICOMDateOptions}.
* @returns The formatted date, or the resolved invalid fallback.
*
* @remarks
* Migration: this previously took a positional format argument
* (`formatDICOMDate(date, strFormat?)`). That format — and the additional
* `fallbackFormat` / `invalidFallback` options — are now passed as a single
* options object.
*
* @example
* // Before (positional):
* formatDICOMDate(date, 'YYYY-MM-DD');
* // After (options object):
* formatDICOMDate(date, { strFormat: 'YYYY-MM-DD' });
*
* @example
* formatDICOMDate(date, { fallbackFormat: 'DD-MMM-YYYY', invalidFallback: '' });
*/
export function formatDICOMDate(date: string, strFormat?: string): string {
export function formatDICOMDate(date: string, options: FormatDICOMDateOptions = {}): string {
const { strFormat, fallbackFormat = 'MMM D, YYYY', invalidFallback } = options;
if (!date) {
return '';
return invalidFallback ?? '';
}
const format = strFormat ?? i18n.t('Common:localDateFormat', 'MMM D, YYYY');
const format = strFormat ?? i18n.t('Common:localDateFormat', fallbackFormat);
const locale = i18n.language || 'en';
const parsed = moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
if (!parsed.isValid()) {
return moment(date).locale(locale).format(format);
// Honor an explicit fallback, otherwise preserve prior behavior (a lenient
// parse, which formats valid non-DICOM strings and yields "Invalid date"
// for unparseable input).
return invalidFallback ?? moment(date).locale(locale).format(format);
}
return parsed.locale(locale).format(format);
@@ -0,0 +1,62 @@
// Mock i18next so `t(key, fallback)` deterministically returns the fallback
// (the test env doesn't initialize i18n with resources).
jest.mock('i18next', () => ({
__esModule: true,
default: {
t: (_key: string, fallback?: string) => fallback,
language: 'en',
},
}));
import { formatDICOMTime } from './formatDICOMTime';
describe('formatDICOMTime', () => {
describe('valid DICOM times (default hh:mm A fallback)', () => {
it('formats HHmmss', () => {
expect(formatDICOMTime('143052')).toBe('02:30 PM');
});
it('formats HHmm', () => {
expect(formatDICOMTime('0905')).toBe('09:05 AM');
});
it('formats HH', () => {
expect(formatDICOMTime('14')).toBe('02:00 PM');
});
it('does not misread a 4-digit HHmm time as a year', () => {
// The whole reason there is no lenient reparse: moment("1430") with no
// format would yield the year 1430, not 14:30.
expect(formatDICOMTime('1430')).toBe('02:30 PM');
});
});
describe('format overrides', () => {
it('uses strFormat when provided', () => {
expect(formatDICOMTime('143052', { strFormat: 'HH:mm:ss' })).toBe('14:30:52');
expect(formatDICOMTime('143052', { strFormat: 'HH:mm' })).toBe('14:30');
});
it('uses fallbackFormat for the locale-key-missing path', () => {
expect(formatDICOMTime('143052', { fallbackFormat: 'HH:mm' })).toBe('14:30');
});
});
describe('empty / invalid input', () => {
it('returns empty string by default for empty input', () => {
expect(formatDICOMTime('')).toBe('');
expect(formatDICOMTime(undefined as unknown as string)).toBe('');
});
it('returns empty string by default for unparseable input', () => {
expect(formatDICOMTime('notatime')).toBe('');
// Out-of-range hour is invalid under strict parsing.
expect(formatDICOMTime('2530')).toBe('');
});
it('returns invalidFallback when provided', () => {
expect(formatDICOMTime('', { invalidFallback: 'N/A' })).toBe('N/A');
expect(formatDICOMTime('notatime', { invalidFallback: '--' })).toBe('--');
});
});
});
@@ -0,0 +1,49 @@
import moment from 'moment';
import i18n from 'i18next';
export interface FormatDICOMTimeOptions {
/**
* Explicit output format. When provided it overrides the locale's
* `Common:localTimeFormat`; when omitted the locale key is used.
*/
strFormat?: string;
/**
* Output format used when the active locale does not define
* `Common:localTimeFormat` (none of the shipped locales do today, so in
* practice this is what renders). Defaults to `hh:mm A`.
*/
fallbackFormat?: string;
/** Returned when `time` is empty or cannot be parsed. Defaults to `''`. */
invalidFallback?: string;
}
/**
* Formats a DICOM time.
*
* @param time - Raw time string (e.g. `HH`, `HHmm`, `HHmmss`, `HHmmss.SSS`).
* @param options - See {@link FormatDICOMTimeOptions}.
* @returns The formatted time, or the resolved invalid fallback.
*
* @example
* formatDICOMTime(time, { invalidFallback: '' });
*/
export function formatDICOMTime(time: string, options: FormatDICOMTimeOptions = {}): string {
const { strFormat, fallbackFormat = 'hh:mm A', invalidFallback } = options;
if (!time) {
return invalidFallback ?? '';
}
const format = strFormat ?? i18n.t('Common:localTimeFormat', fallbackFormat);
const locale = i18n.language || 'en';
const parsed = moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS'], true);
// Unlike formatDICOMDate, there is no lenient reparse: a no-format
// moment(time) treats the string as a date (e.g. "1430" -> year 1430), so an
// unparseable time falls straight back rather than fabricating a wrong value.
if (!parsed.isValid()) {
return invalidFallback ?? '';
}
return parsed.locale(locale).format(format);
}
+10 -1
View File
@@ -2,5 +2,14 @@ import { getToggledClassName } from './getToggledClassName';
import roundNumber from './roundNumber';
import { cn } from '../lib/utils';
import { formatDICOMDate } from './formatDICOMDate';
import { formatDICOMTime } from './formatDICOMTime';
import { parseStudyDateTimestamp } from './parseStudyDateTimestamp';
export { getToggledClassName, roundNumber, cn, formatDICOMDate };
export {
getToggledClassName,
roundNumber,
cn,
formatDICOMDate,
formatDICOMTime,
parseStudyDateTimestamp,
};
@@ -0,0 +1,47 @@
import { parseStudyDateTimestamp } from './parseStudyDateTimestamp';
describe('parseStudyDateTimestamp', () => {
it('parses date + time into the matching local timestamp', () => {
// Month is 0-indexed in the Date constructor; both use local time, so this
// is timezone-independent.
const expected = new Date(2018, 8, 16, 14, 30, 52).getTime();
expect(parseStudyDateTimestamp('20180916', '143052')).toBe(expected);
});
it('defaults to midnight when no time is given', () => {
const expected = new Date(2018, 8, 16, 0, 0, 0, 0).getTime();
expect(parseStudyDateTimestamp('20180916')).toBe(expected);
});
it('ignores an invalid time (treats as midnight)', () => {
expect(parseStudyDateTimestamp('20180916', 'notatime')).toBe(
parseStudyDateTimestamp('20180916')
);
});
it('returns 0 for missing or invalid dates', () => {
expect(parseStudyDateTimestamp(undefined, '143052')).toBe(0);
expect(parseStudyDateTimestamp('')).toBe(0);
expect(parseStudyDateTimestamp('garbage')).toBe(0);
});
describe('ordering (the sort use case)', () => {
it('orders by date', () => {
expect(parseStudyDateTimestamp('20180916')).toBeGreaterThan(
parseStudyDateTimestamp('20180915')
);
});
it('uses time as a tiebreaker within the same date', () => {
expect(parseStudyDateTimestamp('20180916', '130000')).toBeGreaterThan(
parseStudyDateTimestamp('20180916', '120000')
);
});
it('treats equal date + time as a true tie (equal keys)', () => {
expect(parseStudyDateTimestamp('20180916', '120000')).toBe(
parseStudyDateTimestamp('20180916', '120000')
);
});
});
});
@@ -0,0 +1,27 @@
import moment from 'moment';
/**
* Parses a DICOM study date and time into a timestamp for sorting.
*
* @param date - Raw date string (YYYYMMDD or YYYY.MM.DD format)
* @param time - Raw time string (HH, HHmm, HHmmss, or HHmmss.SSS format)
* @returns Timestamp in milliseconds, or 0 if the date is missing/invalid
*/
export function parseStudyDateTimestamp(date?: string, time?: string): number {
const mDate = date && moment(date, ['YYYYMMDD', 'YYYY.MM.DD'], true);
const mTime = time && moment(time, ['HH', 'HHmm', 'HHmmss', 'HHmmss.SSS'], true);
if (mDate && mDate.isValid()) {
const md = mDate.clone();
if (mTime && mTime.isValid()) {
md.set({
hour: mTime.hour(),
minute: mTime.minute(),
second: mTime.second(),
millisecond: mTime.millisecond(),
});
}
return md.toDate().getTime();
}
return 0;
}