diff --git a/apps/web/package.json b/apps/web/package.json index 96c31af..e4888e0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,11 +14,17 @@ "@base-ui/react": "^1.0.0", "@cfdm/shared": "workspace:*", "@cfdm/ui": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.10.0", "@tanstack/react-query": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-router": "^1.130.2", "@tanstack/react-router-devtools": "^1.130.2", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.14.4", "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", "date-fns": "^4.4.0", diff --git a/apps/web/src/components/data-grid-card.tsx b/apps/web/src/components/data-grid-card.tsx new file mode 100644 index 0000000..43466a3 --- /dev/null +++ b/apps/web/src/components/data-grid-card.tsx @@ -0,0 +1,192 @@ +import { useMemo, useState, type ReactNode } from 'react' +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getPaginationRowModel, + flexRender, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table' + +import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card' +import { + DataGrid, + DataGridContainer, +} from '@/components/reui/data-grid/data-grid' +import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' +import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual' +import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' +import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' +import { EmptyState } from './empty-state' + +export interface DataGridCardProps { + title?: ReactNode + description?: ReactNode + actions?: ReactNode + columns: ColumnDef[] + data: TData[] + /** Ключ строки — функция, возвращающая уникальный id. */ + rowId?: (row: TData, index: number) => string + emptyTitle?: string + emptyDescription?: string + emptyAction?: ReactNode + onRowClick?: (row: TData) => void + /** Включить пагинацию. По умолчанию true для >25 строк. */ + pagination?: boolean + /** Размер страницы. По умолчанию 25. */ + pageSize?: number + /** Footer-контент (итоги). */ + footerContent?: ReactNode + /** Плотный layout. */ + dense?: boolean + /** Колонка действий закреплена справа. */ + pinLastColumn?: boolean + /** Сортировка по умолчанию. */ + initialSorting?: SortingState + /** Включить виртуализацию строк (для тяжёлых таблиц). Требует height. */ + virtualization?: boolean + /** Высота viewport для виртуализации (px). По умолчанию 480. */ + height?: number + className?: string +} + +export function DataGridCard({ + title, + description, + actions, + columns, + data, + rowId, + emptyTitle = 'Нет записей', + emptyDescription, + emptyAction, + onRowClick, + pagination, + pageSize = 25, + footerContent, + dense = false, + pinLastColumn = false, + initialSorting, + virtualization = false, + height = 480, + className, +}: DataGridCardProps) { + const [sorting, setSorting] = useState(initialSorting ?? []) + + const lastColId = pinLastColumn ? columns[columns.length - 1]?.id ?? '' : '' + + const columnsWithIds = useMemo(() => { + if (rowId) return columns + return columns + }, [columns, rowId]) + + const showPagination = pagination ?? data.length > pageSize + + const table = useReactTable({ + data, + columns: columnsWithIds, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined, + initialState: { + ...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}), + ...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}), + }, + getRowId: rowId + ? (row, index) => rowId(row, index) + : undefined, + enableColumnPinning: pinLastColumn, + }) + + if (data.length === 0) { + return ( + + {(title || actions) && ( + +
+ {title ? {title} : null} + {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ )} + + + +
+ ) + } + + return ( + + {(title || actions) && ( + +
+ {title ? {title} : null} + {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ )} + + + + {virtualization ? ( + + + + ) : ( + <> + + {showPagination ? : null} + + )} + + + +
+ ) +} + +/** Хелпер для конвертации старых DataTableColumn → ColumnDef. */ +export function columnDefFromDataTable( + cols: { + key: string + header: ReactNode + cell: (row: T, index: number) => ReactNode + className?: string + headerClassName?: string + }[], +): ColumnDef[] { + return cols.map((c) => ({ + id: c.key, + header: () => c.header, + cell: ({ row }) => c.cell(row.original, row.index), + meta: { className: c.className, headerClassName: c.headerClassName }, + })) +} + +/** re-export flexRender для удобства использования в колонках. */ +export { flexRender } diff --git a/apps/web/src/components/reui/badge.tsx b/apps/web/src/components/reui/badge.tsx new file mode 100644 index 0000000..670bcf6 --- /dev/null +++ b/apps/web/src/components/reui/badge.tsx @@ -0,0 +1,98 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" + +const badgeVariants = cva( + "relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground", + outline: "border-border bg-transparent dark:bg-input/32", + secondary: "bg-secondary text-secondary-foreground", + info: "bg-info text-white", + success: "bg-success text-white", + warning: "bg-warning text-white", + destructive: "bg-destructive text-white", + focus: "bg-focus text-focus-foreground", + invert: "bg-invert text-invert-foreground", + "primary-light": + "bg-primary/10 border-none text-primary dark:bg-primary/20", + "warning-light": + "bg-warning/10 border-none text-warning-foreground dark:bg-warning/20", + "success-light": + "bg-success/10 border-none text-success-foreground dark:bg-success/20", + "info-light": + "bg-info/10 border-none text-info-foreground dark:bg-info/20", + "destructive-light": + "bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20", + "invert-light": + "bg-invert/10 border-none text-foreground dark:bg-invert/20", + "focus-light": + "bg-focus/10 border-none text-focus-foreground dark:bg-focus/20", + "primary-outline": + "bg-background border-border text-primary dark:bg-input/30", + "warning-outline": + "bg-background border-border text-warning-foreground dark:bg-input/30", + "success-outline": + "bg-background border-border text-success-foreground dark:bg-input/30", + "info-outline": + "bg-background border-border text-info-foreground dark:bg-input/30", + "destructive-outline": + "bg-background border-border text-destructive-foreground dark:bg-input/30", + "invert-outline": + "bg-background border-border text-invert-foreground dark:bg-input/30", + "focus-outline": + "bg-background border-border text-focus-foreground dark:bg-input/30", + }, + size: { + xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1", + sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1", + default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1", + lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1", + xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5", + }, + /** `default`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */ + radius: { + default: + "rounded-sm", + full: "rounded-full", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + radius: "default", + }, + } +) + +interface BadgeProps extends useRender.ComponentProps<"span"> { + variant?: VariantProps["variant"] + size?: VariantProps["size"] + radius?: VariantProps["radius"] +} + +function Badge({ + className, + variant, + size, + radius, + render, + ...props +}: BadgeProps) { + const defaultProps = { + "data-slot": "badge", + className: cn(badgeVariants({ variant, size, radius, className })), + } + + return useRender({ + defaultTagName: "span", + render, + props: mergeProps<"span">(defaultProps, props), + }) +} + +export { Badge, badgeVariants, type BadgeProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx new file mode 100644 index 0000000..14af73d --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-filter.tsx @@ -0,0 +1,165 @@ +import { useMemo, useState } from "react" +import { Badge } from "@/components/reui/badge" +import { type Column } from "@tanstack/react-table" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { Input } from "@cfdm/ui/components/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@cfdm/ui/components/popover" +import { Separator } from "@cfdm/ui/components/separator" +import { CirclePlusIcon, CheckIcon } from "lucide-react" + +interface DataGridColumnFilterProps { + column?: Column + title?: string + options: { + label: string + value: string + icon?: React.ComponentType<{ className?: string }> + }[] +} + +function DataGridColumnFilter({ + column, + title, + options, +}: DataGridColumnFilterProps) { + const facets = column?.getFacetedUniqueValues() + const selectedValues = new Set(column?.getFilterValue() as string[]) + const [searchQuery, setSearchQuery] = useState("") + + const filteredOptions = useMemo(() => { + if (!searchQuery) return options + return options.filter((option) => + option.label.toLowerCase().includes(searchQuery.toLowerCase()) + ) + }, [options, searchQuery]) + + return ( + + + + {title} + {selectedValues?.size > 0 && ( + <> + + + {selectedValues.size} + +
+ {selectedValues.size > 2 ? ( + + {selectedValues.size} selected + + ) : ( + options + .filter((option) => selectedValues.has(option.value)) + .map((option) => ( + + {option.label} + + )) + )} +
+ + )} + + } + /> + +
+ setSearchQuery(e.target.value)} + className="h-8" + /> +
+
+ {filteredOptions.length === 0 ? ( +
+ No results found. +
+ ) : ( +
+ {filteredOptions.map((option) => { + const isSelected = selectedValues.has(option.value) + return ( +
{ + if (isSelected) { + selectedValues.delete(option.value) + } else { + selectedValues.add(option.value) + } + const filterValues = Array.from(selectedValues) + column?.setFilterValue( + filterValues.length ? filterValues : undefined + ) + }} + className={cn( + "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none", + "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground" + )} + > +
+ +
+ {option.icon && ( + + )} + {option.label} + {facets?.get(option.value) && ( + + {facets.get(option.value)} + + )} +
+ ) + })} +
+ )} + {selectedValues.size > 0 && ( + <> +
+
+
column?.setFilterValue(undefined)} + className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none" + > + Clear filters +
+
+ + )} +
+ + + ) +} + +export { DataGridColumnFilter, type DataGridColumnFilterProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx new file mode 100644 index 0000000..fc30804 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-header.tsx @@ -0,0 +1,345 @@ +"use client" + +import { type HTMLAttributes, memo, type ReactNode, useMemo } from "react" +import { + getColumnHeaderLabel, + useDataGrid, +} from "@/components/reui/data-grid/data-grid" +import { type Column } from "@tanstack/react-table" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" +import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react" + +interface DataGridColumnHeaderProps< + TData, + TValue, +> extends HTMLAttributes { + column: Column + /** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */ + title?: string + icon?: ReactNode + pinnable?: boolean + filter?: ReactNode + visibility?: boolean +} + +function DataGridColumnHeaderInner({ + column, + title, + icon, + className, + filter, + visibility = false, +}: DataGridColumnHeaderProps) { + const { isLoading, table, props, recordCount } = useDataGrid() + const resolvedTitle = title ?? getColumnHeaderLabel(column) + + const columnOrder = table.getState().columnOrder + const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility) + const isSorted = column.getIsSorted() + const isPinned = column.getIsPinned() + const canSort = column.getCanSort() + const canPin = column.getCanPin() + const canResize = column.getCanResize() + + const columnIndex = columnOrder.indexOf(column.id) + const canMoveLeft = columnIndex > 0 + const canMoveRight = columnIndex < columnOrder.length - 1 + + const handleSort = () => { + if (isSorted === "asc") { + column.toggleSorting(true) + } else if (isSorted === "desc") { + column.clearSorting() + } else { + column.toggleSorting(false) + } + } + + const headerLabelClassName = cn( + "text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5", + className + ) + + const headerButtonClassName = cn( + "text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg", + className + ) + + const sortIcon = + canSort && + (isSorted === "desc" ? ( + + ) : isSorted === "asc" ? ( + + ) : ( + + )) + + const hasControls = + props.tableLayout?.columnsMovable || + (props.tableLayout?.columnsVisibility && visibility) || + (props.tableLayout?.columnsPinnable && canPin) || + filter + + const menuItems = useMemo(() => { + const items: ReactNode[] = [] + let hasPreviousSection = false + + // Filter section + if (filter) { + items.push( + + {filter} + + ) + hasPreviousSection = true + } + + // Sort section + if (canSort) { + if (hasPreviousSection) { + items.push() + } + items.push( + { + if (isSorted === "asc") { + column.clearSorting() + } else { + column.toggleSorting(false) + } + }} + disabled={!canSort} + > + + Asc + {isSorted === "asc" && ( + + )} + , + { + if (isSorted === "desc") { + column.clearSorting() + } else { + column.toggleSorting(true) + } + }} + disabled={!canSort} + > + + Desc + {isSorted === "desc" && ( + + )} + + ) + hasPreviousSection = true + } + + // Pin section + if (props.tableLayout?.columnsPinnable && canPin) { + if (hasPreviousSection) { + items.push() + } + items.push( + column.pin(isPinned === "left" ? false : "left")} + > + , + column.pin(isPinned === "right" ? false : "right")} + > + + ) + hasPreviousSection = true + } + + // Move section + if (props.tableLayout?.columnsMovable) { + if (hasPreviousSection) { + items.push() + } + items.push( + { + if (columnIndex > 0) { + const newOrder = [...columnOrder] + const [movedColumn] = newOrder.splice(columnIndex, 1) + newOrder.splice(columnIndex - 1, 0, movedColumn) + table.setColumnOrder(newOrder) + } + }} + disabled={!canMoveLeft || isPinned !== false} + > + , + { + if (columnIndex < columnOrder.length - 1) { + const newOrder = [...columnOrder] + const [movedColumn] = newOrder.splice(columnIndex, 1) + newOrder.splice(columnIndex + 1, 0, movedColumn) + table.setColumnOrder(newOrder) + } + }} + disabled={!canMoveRight || isPinned !== false} + > + + ) + hasPreviousSection = true + } + + // Visibility section + if (props.tableLayout?.columnsVisibility && visibility) { + if (hasPreviousSection) { + items.push() + } + items.push( + + + + Columns + + + {table + .getAllColumns() + .filter((col) => col.getCanHide()) + .map((col) => ( + event.preventDefault()} + onCheckedChange={(value) => col.toggleVisibility(!!value)} + className="capitalize" + > + {getColumnHeaderLabel(col)} + + ))} + + + ) + } + + return items + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + filter, + canSort, + isSorted, + column, + props.tableLayout?.columnsPinnable, + props.tableLayout?.columnsMovable, + props.tableLayout?.columnsVisibility, + canPin, + isPinned, + canMoveLeft, + canMoveRight, + visibility, + table, + columnIndex, + columnOrder, + columnVisibilityKey, // Needed to update checkbox states when visibility changes + ]) + + if (hasControls) { + return ( +
+ + + {icon && icon} + {resolvedTitle} + {sortIcon} + + } + /> + + {menuItems} + + + {props.tableLayout?.columnsPinnable && canPin && isPinned && ( + + )} +
+ ) + } + + if (canSort || (props.tableLayout?.columnsResizable && canResize)) { + return ( +
+ +
+ ) + } + + return ( +
+ {icon && icon} + {resolvedTitle} +
+ ) +} + +const DataGridColumnHeader = memo( + DataGridColumnHeaderInner +) as typeof DataGridColumnHeaderInner + +export { DataGridColumnHeader, type DataGridColumnHeaderProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx new file mode 100644 index 0000000..94ccb05 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-column-visibility.tsx @@ -0,0 +1,51 @@ +import { type ReactElement } from "react" +import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid" +import { type Table } from "@tanstack/react-table" + +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@cfdm/ui/components/dropdown-menu" + +function DataGridColumnVisibility({ + table, + trigger, +}: { + table: Table + trigger: ReactElement> +}) { + return ( + + + + + + Toggle Columns + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + event.preventDefault()} + onCheckedChange={(value) => column.toggleVisibility(!!value)} + > + {getColumnHeaderLabel(column)} + + ) + })} + + + + ) +} + +export { DataGridColumnVisibility } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx new file mode 100644 index 0000000..c0cff54 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-pagination.tsx @@ -0,0 +1,226 @@ +"use client" + +import React, { type ReactNode } from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@cfdm/ui/components/select" +import { Skeleton } from "@cfdm/ui/components/skeleton" +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react" + +interface DataGridPaginationProps { + sizes?: number[] + sizesInfo?: string + sizesLabel?: string + sizesDescription?: string + sizesSkeleton?: ReactNode + more?: boolean + moreLimit?: number + info?: string + infoSkeleton?: ReactNode + className?: string + rowsPerPageLabel?: string + previousPageLabel?: string + nextPageLabel?: string + ellipsisText?: string +} + +function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element { + const { table, recordCount, isLoading } = useDataGrid() + + const defaultProps: Partial = { + sizes: [5, 10, 25, 50, 100], + sizesLabel: "Show", + sizesDescription: "per page", + sizesSkeleton: , + moreLimit: 5, + more: false, + info: "{from} - {to} of {count}", + infoSkeleton: , + rowsPerPageLabel: "Rows per page", + previousPageLabel: "Go to previous page", + nextPageLabel: "Go to next page", + ellipsisText: "...", + } + + const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props } + + const btnBaseClasses = "size-7 p-0 text-sm" + const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180" + const pageIndex = table.getState().pagination.pageIndex + const pageSize = table.getState().pagination.pageSize + const from = pageIndex * pageSize + 1 + const to = Math.min((pageIndex + 1) * pageSize, recordCount) + const pageCount = table.getPageCount() + + // Replace placeholders in paginationInfo + const paginationInfo = mergedProps?.info + ? mergedProps.info + .replace("{from}", from.toString()) + .replace("{to}", to.toString()) + .replace("{count}", recordCount.toString()) + : `${from} - ${to} of ${recordCount}` + + // Pagination limit logic + const paginationMoreLimit = mergedProps?.moreLimit || 5 + + // Determine the start and end of the pagination group + const currentGroupStart = + Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit + const currentGroupEnd = Math.min( + currentGroupStart + paginationMoreLimit, + pageCount + ) + + // Render page buttons based on the current group + const renderPageButtons = () => { + const buttons = [] + for (let i = currentGroupStart; i < currentGroupEnd; i++) { + buttons.push( + + ) + } + return buttons + } + + // Render a "previous" ellipsis button if there are previous pages to show + const renderEllipsisPrevButton = () => { + if (currentGroupStart > 0) { + return ( + + ) + } + return null + } + + // Render a "next" ellipsis button if there are more pages to show after the current group + const renderEllipsisNextButton = () => { + if (currentGroupEnd < pageCount) { + return ( + + ) + } + return null + } + + return ( +
+
+ {isLoading ? ( + mergedProps?.sizesSkeleton + ) : ( + <> +
+ {mergedProps.rowsPerPageLabel} +
+ + + )} +
+
+ {isLoading ? ( + mergedProps?.infoSkeleton + ) : ( + <> +
+ {paginationInfo} +
+ {pageCount > 1 && ( +
+ + + {renderEllipsisPrevButton()} + + {renderPageButtons()} + + {renderEllipsisNextButton()} + + +
+ )} + + )} +
+
+ ) +} + +export { DataGridPagination, type DataGridPaginationProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx b/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx new file mode 100644 index 0000000..a31f774 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-scroll-area.tsx @@ -0,0 +1,419 @@ +import { + type PointerEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" + +import { cn } from "@cfdm/ui/lib/utils" + +const MIN_THUMB_SIZE = 24 +const FALLBACK_SCROLLBAR_SIZE = 12 + +const INITIAL_METRICS = { + hasVerticalOverflow: false, + headerHeight: 0, + horizontalScrollbarSize: 0, + thumbHeight: 0, + thumbTop: 0, + trackHeight: 0, +} as const + +type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both" + +type ScrollbarMetrics = { + hasVerticalOverflow: boolean + headerHeight: number + horizontalScrollbarSize: number + thumbHeight: number + thumbTop: number + trackHeight: number +} + +type ObservedElements = { + header: HTMLElement | null + horizontalScrollbar: HTMLElement | null + table: HTMLElement | null + tableViewport: HTMLElement | null +} + +type DataGridScrollAreaProps = Omit< + ScrollAreaPrimitive.Root.Props, + "children" +> & { + children: ReactNode + orientation?: DataGridScrollAreaOrientation +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) { + return ( + next.hasVerticalOverflow === prev.hasVerticalOverflow && + next.headerHeight === prev.headerHeight && + next.horizontalScrollbarSize === prev.horizontalScrollbarSize && + next.thumbHeight === prev.thumbHeight && + next.thumbTop === prev.thumbTop && + next.trackHeight === prev.trackHeight + ) +} + +function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) { + element.style.setProperty( + "--data-grid-scrollbar-header-height", + `${metrics.headerHeight}px` + ) + element.style.setProperty( + "--data-grid-scrollbar-thumb-height", + `${metrics.thumbHeight}px` + ) + element.style.setProperty( + "--data-grid-scrollbar-thumb-top", + `${metrics.thumbTop}px` + ) + element.style.setProperty( + "--data-grid-scrollbar-track-height", + `${metrics.trackHeight}px` + ) +} + +function DataGridScrollArea({ + children, + className, + orientation = "both", + ...props +}: DataGridScrollAreaProps) { + const { props: dataGridProps } = useDataGrid() + const containerRef = useRef(null) + const viewportRef = useRef(null) + const dragRef = useRef<{ + pointerId: number + startScrollTop: number + startY: number + } | null>(null) + const metricsRef = useRef(INITIAL_METRICS) + const observedElementsRef = useRef({ + header: null, + horizontalScrollbar: null, + table: null, + tableViewport: null, + }) + + const showHorizontal = orientation !== "vertical" + const showVertical = orientation !== "horizontal" + const usesCustomVerticalScrollbar = + showVertical && !!dataGridProps.tableLayout?.headerSticky + const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] = + useState(false) + + const clearDragState = useCallback(() => { + dragRef.current = null + document.body.style.userSelect = "" + document.body.style.webkitUserSelect = "" + }, []) + + const resetMetrics = useCallback(() => { + const container = containerRef.current + + if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) { + applyMetrics(container, INITIAL_METRICS) + metricsRef.current = INITIAL_METRICS + } + + setHasCustomVerticalOverflow((prev) => (prev ? false : prev)) + }, []) + + const syncCustomVerticalScrollbar = useCallback(() => { + const container = containerRef.current + const viewport = viewportRef.current + + if (!container || !viewport || !usesCustomVerticalScrollbar) { + resetMetrics() + return + } + + const { header, horizontalScrollbar } = observedElementsRef.current + const headerHeight = header?.getBoundingClientRect().height ?? 0 + const viewportHeight = viewport.clientHeight + const viewportWidth = viewport.clientWidth + const scrollHeight = viewport.scrollHeight + const scrollWidth = viewport.scrollWidth + const hasHorizontalOverflow = + showHorizontal && scrollWidth > viewportWidth + 0.5 + const horizontalScrollbarSize = hasHorizontalOverflow + ? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE + : 0 + const trackHeight = Math.max( + 0, + viewportHeight - headerHeight - horizontalScrollbarSize + ) + const maxScroll = Math.max(0, scrollHeight - viewportHeight) + + let nextMetrics: ScrollbarMetrics + + if (trackHeight === 0 || maxScroll === 0) { + nextMetrics = { + hasVerticalOverflow: false, + headerHeight, + horizontalScrollbarSize, + thumbHeight: trackHeight, + thumbTop: 0, + trackHeight, + } + } else { + const bodyContentHeight = Math.max( + trackHeight, + scrollHeight - headerHeight + ) + const thumbHeight = clamp( + trackHeight * (trackHeight / bodyContentHeight), + MIN_THUMB_SIZE, + trackHeight + ) + const maxThumbTop = Math.max(0, trackHeight - thumbHeight) + const thumbTop = + maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0 + + nextMetrics = { + hasVerticalOverflow: true, + headerHeight, + horizontalScrollbarSize, + thumbHeight, + thumbTop, + trackHeight, + } + } + + if (!areMetricsEqual(nextMetrics, metricsRef.current)) { + applyMetrics(container, nextMetrics) + metricsRef.current = nextMetrics + } + + setHasCustomVerticalOverflow((prev) => + prev === nextMetrics.hasVerticalOverflow + ? prev + : nextMetrics.hasVerticalOverflow + ) + }, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar]) + + useEffect(() => { + const container = containerRef.current + const viewport = viewportRef.current + + if (!container || !viewport) return + + if (!usesCustomVerticalScrollbar) { + resetMetrics() + return + } + + observedElementsRef.current = { + header: container.querySelector( + '[data-slot="data-grid-table"] thead' + ) as HTMLElement | null, + horizontalScrollbar: container.querySelector( + '[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]' + ) as HTMLElement | null, + table: container.querySelector( + '[data-slot="data-grid-table"]' + ) as HTMLElement | null, + tableViewport: container.querySelector( + '[data-slot="data-grid-table-viewport"]' + ) as HTMLElement | null, + } + + let frame = 0 + + const scheduleSync = () => { + cancelAnimationFrame(frame) + frame = window.requestAnimationFrame(syncCustomVerticalScrollbar) + } + + scheduleSync() + viewport.addEventListener("scroll", scheduleSync, { passive: true }) + + const observer = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(scheduleSync) + + observer?.observe(viewport) + observedElementsRef.current.header && + observer?.observe(observedElementsRef.current.header) + observedElementsRef.current.table && + observer?.observe(observedElementsRef.current.table) + observedElementsRef.current.tableViewport && + observer?.observe(observedElementsRef.current.tableViewport) + + return () => { + cancelAnimationFrame(frame) + observer?.disconnect() + viewport.removeEventListener("scroll", scheduleSync) + clearDragState() + } + }, [ + clearDragState, + resetMetrics, + syncCustomVerticalScrollbar, + usesCustomVerticalScrollbar, + ]) + + const scrollToThumbOffset = (nextThumbTop: number) => { + const viewport = viewportRef.current + const { thumbHeight, trackHeight } = metricsRef.current + + if (!viewport) return + + const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + const maxThumbTop = Math.max(0, trackHeight - thumbHeight) + + if (maxScroll === 0 || maxThumbTop === 0) { + viewport.scrollTop = 0 + return + } + + const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop + viewport.scrollTop = ratio * maxScroll + } + + const handleThumbPointerDown = (event: PointerEvent) => { + const viewport = viewportRef.current + + if (!viewport) return + + event.preventDefault() + event.stopPropagation() + event.currentTarget.setPointerCapture(event.pointerId) + + dragRef.current = { + pointerId: event.pointerId, + startScrollTop: viewport.scrollTop, + startY: event.clientY, + } + + document.body.style.userSelect = "none" + document.body.style.webkitUserSelect = "none" + } + + const handleThumbPointerMove = (event: PointerEvent) => { + const viewport = viewportRef.current + const dragState = dragRef.current + const { thumbHeight, trackHeight } = metricsRef.current + + if (!viewport || !dragState || dragState.pointerId !== event.pointerId) { + return + } + + const maxThumbTop = Math.max(0, trackHeight - thumbHeight) + const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + + if (maxThumbTop === 0 || maxScroll === 0) return + + const deltaY = event.clientY - dragState.startY + const nextScrollTop = + dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll + + viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll) + } + + const handleThumbPointerUp = (event: PointerEvent) => { + if (dragRef.current?.pointerId !== event.pointerId) return + clearDragState() + } + + const handleTrackPointerDown = (event: PointerEvent) => { + const { thumbHeight } = metricsRef.current + + if (event.target !== event.currentTarget) return + + event.preventDefault() + event.stopPropagation() + + const rect = event.currentTarget.getBoundingClientRect() + const offsetY = event.clientY - rect.top - thumbHeight / 2 + + scrollToThumbOffset(offsetY) + } + + return ( +
+ + + + {children} + + + + {showHorizontal && ( + + + + )} + + {showVertical && !usesCustomVerticalScrollbar && ( + + + + )} + + + {usesCustomVerticalScrollbar && hasCustomVerticalOverflow && ( + + ) +} + +export { DataGridScrollArea } +export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx new file mode 100644 index 0000000..356e23b --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-dnd-rows.tsx @@ -0,0 +1,309 @@ +"use client" + +import { + createContext, + type CSSProperties, + type ReactNode, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + DataGridTableBase, + DataGridTableBody, + DataGridTableBodyRow, + DataGridTableBodyRowCell, + DataGridTableBodyRowSkeleton, + DataGridTableBodyRowSkeletonCell, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableRowSpacer, + DataGridTableViewport, +} from "@/components/reui/data-grid/data-grid-table" +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + type UniqueIdentifier, + useSensor, + useSensors, + type DragEndEvent, + type Modifier, +} from "@dnd-kit/core" +import { restrictToVerticalAxis } from "@dnd-kit/modifiers" +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { type Cell, flexRender, type HeaderGroup, type Row } from "@tanstack/react-table" + +import { cn } from "@cfdm/ui/lib/utils" +import { Button } from "@cfdm/ui/components/button" +import { GripHorizontalIcon } from "lucide-react" + +// Context to share sortable listeners from row to handle +type SortableContextValue = ReturnType +const SortableRowContext = createContext | null>(null) + +function DataGridTableDndRowHandle({ className }: { className?: string }) { + const context = useContext(SortableRowContext) + + if (!context) { + // Fallback if context is not available (shouldn't happen in normal usage) + return ( + + ) + } + + return ( + + ) +} + +function DataGridTableDndRow({ row }: { row: Row }) { + const { + transform, + transition, + setNodeRef, + isDragging, + attributes, + listeners, + } = useSortable({ + id: row.id, + }) + + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: transition, + opacity: isDragging ? 0.8 : 1, + zIndex: isDragging ? 1 : 0, + position: "relative", + cursor: isDragging ? "grabbing" : undefined, + } + + return ( + + + {row.getVisibleCells().map((cell: Cell, colIndex) => { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + + ) +} + +function DataGridTableDndRows({ + handleDragEnd, + dataIds, + footerContent, +}: { + handleDragEnd: (event: DragEndEvent) => void + dataIds: UniqueIdentifier[] + footerContent?: ReactNode +}) { + const { table, isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + const tableContainerRef = useRef(null) + const [isDraggingRow, setIsDraggingRow] = useState(false) + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ) + + useEffect(() => { + if (!isDraggingRow) return + + const { body, documentElement } = document + const previousBodyCursor = body.style.cursor + const previousDocumentCursor = documentElement.style.cursor + + body.style.cursor = "grabbing" + documentElement.style.cursor = "grabbing" + + return () => { + body.style.cursor = previousBodyCursor + documentElement.style.cursor = previousDocumentCursor + } + }, [isDraggingRow]) + + const modifiers = useMemo(() => { + const restrictToTableContainer: Modifier = ({ + transform, + draggingNodeRect, + }) => { + if (!tableContainerRef.current || !draggingNodeRect) { + return transform + } + + const containerRect = tableContainerRef.current.getBoundingClientRect() + const { x, y } = transform + + const minX = containerRect.left - draggingNodeRect.left + const maxX = containerRect.right - draggingNodeRect.right + const minY = containerRect.top - draggingNodeRect.top + const maxY = containerRect.bottom - draggingNodeRect.bottom + + return { + ...transform, + x: Math.max(minX, Math.min(maxX, x)), + y: Math.max(minY, Math.min(maxY, y)), + } + } + + return [restrictToVerticalAxis, restrictToTableContainer] + }, []) + + return ( + setIsDraggingRow(false)} + onDragEnd={(event) => { + setIsDraggingRow(false) + handleDragEnd(event) + }} + onDragStart={() => setIsDraggingRow(true)} + sensors={sensors} + > + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+ + {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + {props.loadingMode === "skeleton" && + isLoading && + pagination?.pageSize ? ( + Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => { + return ( + + {column.columnDef.meta?.skeleton} + + ) + })} + + )) + ) : table.getRowModel().rows.length ? ( + + {table.getRowModel().rows.map((row: Row) => { + return + })} + + ) : ( + + )} + + + {footerContent && ( + {footerContent} + )} +
+
+
+ ) +} + +export { DataGridTableDndRowHandle, DataGridTableDndRows } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx new file mode 100644 index 0000000..798188c --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-dnd.tsx @@ -0,0 +1,312 @@ +import { + type CSSProperties, + Fragment, + type ReactNode, + useEffect, + useId, + useRef, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + DataGridTableBase, + DataGridTableBody, + DataGridTableBodyRow, + DataGridTableBodyRowCell, + DataGridTableBodyRowExpandded, + DataGridTableBodyRowSkeleton, + DataGridTableBodyRowSkeletonCell, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableRowSpacer, + DataGridTableViewport, +} from "@/components/reui/data-grid/data-grid-table" +import { + closestCenter, + DndContext, + KeyboardSensor, + type Modifier, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core" +import { + horizontalListSortingStrategy, + SortableContext, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { + type Cell, + flexRender, + type Header, + type HeaderGroup, + type Row, +} from "@tanstack/react-table" + +import { Button } from "@cfdm/ui/components/button" +import { GripVerticalIcon } from "lucide-react" + +function DataGridTableDndHeader({ + header, +}: { + header: Header +}) { + const { props } = useDataGrid() + const { column } = header + + // Check if column ordering is enabled for this column + const canOrder = + (column.columnDef as { enableColumnOrdering?: boolean }) + .enableColumnOrdering !== false + + const { + attributes, + isDragging, + listeners, + setNodeRef, + transform, + transition, + } = useSortable({ + id: header.column.id, + }) + + const style: CSSProperties = { + opacity: isDragging ? 0.8 : 1, + position: "relative", + transform: CSS.Translate.toString(transform), + transition, + cursor: isDragging ? "grabbing" : undefined, + whiteSpace: "nowrap", + width: props.tableLayout?.columnsResizable + ? `calc(var(--header-${header.id}-size) * 1px)` + : header.column.getSize(), + zIndex: isDragging ? 1 : 0, + } + + return ( + +
+ {canOrder && ( + + )} + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + {props.tableLayout?.columnsResizable && column.getCanResize() && ( + + )} +
+
+ ) +} + +function DataGridTableDndCell({ cell }: { cell: Cell }) { + const { props } = useDataGrid() + const { isDragging, setNodeRef, transform, transition } = useSortable({ + id: cell.column.id, + }) + + const style: CSSProperties = { + opacity: isDragging ? 0.8 : 1, + position: "relative", + transform: CSS.Translate.toString(transform), + transition, + cursor: isDragging ? "grabbing" : undefined, + width: props.tableLayout?.columnsResizable + ? `calc(var(--col-${cell.column.id}-size) * 1px)` + : cell.column.getSize(), + zIndex: isDragging ? 1 : 0, + } + + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) +} + +function DataGridTableDnd({ + handleDragEnd, + footerContent, +}: { + handleDragEnd: (event: DragEndEvent) => void + footerContent?: ReactNode +}) { + const { table, isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + const containerRef = useRef(null) + const [isDraggingColumn, setIsDraggingColumn] = useState(false) + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ) + + useEffect(() => { + if (!isDraggingColumn) return + + const { body, documentElement } = document + const previousBodyCursor = body.style.cursor + const previousDocumentCursor = documentElement.style.cursor + + body.style.cursor = "grabbing" + documentElement.style.cursor = "grabbing" + + return () => { + body.style.cursor = previousBodyCursor + documentElement.style.cursor = previousDocumentCursor + } + }, [isDraggingColumn]) + + // Custom modifier to restrict dragging within table bounds with edge offset + const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => { + if (!draggingNodeRect || !containerRef.current) { + return { ...transform, y: 0 } + } + + const containerRect = containerRef.current.getBoundingClientRect() + const edgeOffset = 0 + + const minX = containerRect.left - draggingNodeRect.left - edgeOffset + const maxX = + containerRect.right - + draggingNodeRect.left - + draggingNodeRect.width + + edgeOffset + + return { + ...transform, + x: Math.min(Math.max(transform.x, minX), maxX), + y: 0, // Lock vertical movement + } + } + + return ( + setIsDraggingColumn(false)} + onDragEnd={(event) => { + setIsDraggingColumn(false) + handleDragEnd(event) + }} + onDragStart={() => setIsDraggingColumn(true)} + sensors={sensors} + > + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + + {headerGroup.headers.map((header) => ( + + ))} + + + ) + })} + + + {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + {props.loadingMode === "skeleton" && + isLoading && + pagination?.pageSize ? ( + Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => { + return ( + + {column.columnDef.meta?.skeleton} + + ) + })} + + )) + ) : table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row: Row) => { + return ( + + + {row + .getVisibleCells() + .map((cell: Cell) => { + return ( + + + + ) + })} + + {row.getIsExpanded() && ( + + )} + + ) + }) + ) : ( + + )} + + + {footerContent && ( + {footerContent} + )} + + + + ) +} + +export { DataGridTableDnd } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx new file mode 100644 index 0000000..7c1ee6b --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table-virtual.tsx @@ -0,0 +1,493 @@ +"use client" + +import { + memo, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + DataGridTableBase, + DataGridTableBody, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableRenderedRow, + DataGridTableRowSpacer, + DataGridTableViewport, + getDataGridTableRowSections, +} from "@/components/reui/data-grid/data-grid-table" +import { flexRender, type HeaderGroup, type Row, type Table } from "@tanstack/react-table" +import { + useVirtualizer, + type VirtualItem, + type Virtualizer, + type VirtualizerOptions, +} from "@tanstack/react-virtual" + +import { cn } from "@cfdm/ui/lib/utils" +import { Spinner } from "@cfdm/ui/components/spinner" + +type DataGridTableVirtualScrollElements = { + containerElement: HTMLDivElement | null + scrollElement: HTMLElement | null +} + +type DataGridTableVirtualizerInstance = Virtualizer< + HTMLElement, + HTMLTableRowElement +> + +type DataGridTableVirtualizerOptions = Omit< + VirtualizerOptions, + "count" | "estimateSize" | "getItemKey" | "getScrollElement" +> & { + estimateSize?: (index: number, row: Row) => number + getItemKey?: (index: number, row: Row) => string | number + getScrollElement?: ( + elements: DataGridTableVirtualScrollElements + ) => HTMLElement | null +} + +interface DataGridTableVirtualProps { + height?: number | string + estimateSize?: number + overscan?: number + footerContent?: ReactNode + renderHeader?: boolean + onFetchMore?: () => void + isFetchingMore?: boolean + hasMore?: boolean + fetchMoreOffset?: number + virtualizerOptions?: DataGridTableVirtualizerOptions +} + +interface VirtualBodyProps { + table: Table + columnCount: number + topRows: Row[] + centerRows: Row[] + bottomRows: Row[] + virtualItems: VirtualItem[] + totalSize: number + isVirtualizationEnabled: boolean + isInfiniteMode: boolean + isFetchingMore: boolean + hasMore?: boolean + loadingMoreMessage: ReactNode + allRowsLoadedMessage: ReactNode + measureRowRef?: (element: HTMLTableRowElement | null) => void +} + +function DataGridTableVirtualSpacer({ + columnCount, + height, +}: { + columnCount: number + height: number +}) { + if (height <= 0) return null + + return ( + + + + ) +} + +function DataGridTableVirtualStatusRow({ + children, + className, + columnCount, +}: { + children: ReactNode + className?: string + columnCount: number +}) { + return ( + + + {children} + + + ) +} + +function DataGridTableVirtualBody({ + table: _table, + columnCount, + topRows, + centerRows, + bottomRows, + virtualItems, + totalSize, + isVirtualizationEnabled, + isInfiniteMode, + isFetchingMore, + hasMore, + loadingMoreMessage, + allRowsLoadedMessage, + measureRowRef, +}: VirtualBodyProps) { + void _table + const totalRows = topRows.length + centerRows.length + bottomRows.length + + if (!totalRows) return + + const hasCenterRows = centerRows.length > 0 + const showFetchingRow = isInfiniteMode && isFetchingMore + const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0 + const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow + const leadingSpacerHeight = + isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0 + ? (virtualItems[0]?.start ?? 0) + : 0 + const trailingSpacerHeight = + isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0 + ? Math.max( + 0, + totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0) + ) + : 0 + + const renderedRows: ReactNode[] = [] + + topRows.forEach((row, index) => { + renderedRows.push( + + ) + }) + + if (isVirtualizationEnabled) { + if (leadingSpacerHeight > 0) { + renderedRows.push( + + ) + } + + virtualItems.forEach((virtualRow) => { + const row = centerRows[virtualRow.index] + + if (!row) return + + renderedRows.push( + + ) + }) + + if (trailingSpacerHeight > 0) { + renderedRows.push( + + ) + } + } else { + centerRows.forEach((row) => { + renderedRows.push() + }) + } + + if (showFetchingRow) { + renderedRows.push( + +
+ + {loadingMoreMessage} +
+
+ ) + } + + if (showCompleteRow) { + renderedRows.push( + + {allRowsLoadedMessage} + + ) + } + + bottomRows.forEach((row, index) => { + renderedRows.push( + 0 || hasMiddleSection) + ? "bottom" + : undefined + } + /> + ) + }) + + return <>{renderedRows} +} + +/** + * Memoized virtual body: skip re-renders during active column resize. + * Column widths update via CSS variables on the element, + * so the browser handles width changes without React re-renders. + */ +const MemoizedVirtualBody = memo( + DataGridTableVirtualBody, + (_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn +) as typeof DataGridTableVirtualBody + +function DataGridTableVirtual({ + height, + estimateSize = 48, + overscan = 10, + footerContent, + renderHeader = true, + onFetchMore, + isFetchingMore = false, + hasMore, + fetchMoreOffset = 0, + virtualizerOptions, +}: DataGridTableVirtualProps) { + const { table, props } = useDataGrid() + const { topRows, centerRows, bottomRows } = getDataGridTableRowSections( + table, + props.tableLayout?.rowsPinnable + ) + const columnCount = + table.getVisibleFlatColumns().length + + (props.tableLayout?.columnsResizable ? 1 : 0) + const isInfiniteMode = typeof onFetchMore === "function" + const [viewportElements, setViewportElements] = + useState({ + containerElement: null, + scrollElement: null, + }) + + const { + estimateSize: customEstimateSize, + getItemKey: customGetItemKey, + getScrollElement: customGetScrollElement, + measureElement: customMeasureElement, + overscan: customOverscan, + ...virtualizerOptionsRest + } = virtualizerOptions ?? {} + + const isVirtualizationEnabled = virtualizerOptions?.enabled !== false + const loadingMoreMessage = + props.fetchingMoreMessage || props.loadingMessage || "Loading..." + const allRowsLoadedMessage = + props.allRowsLoadedMessage || "All records loaded" + + const handleViewportRef = useCallback((node: HTMLDivElement | null) => { + setViewportElements({ + containerElement: node, + scrollElement: + (node?.closest( + '[data-slot="scroll-area-viewport"]' + ) as HTMLElement | null) ?? node, + }) + }, []) + + const usesExternalScrollArea = + viewportElements.scrollElement !== null && + viewportElements.scrollElement !== viewportElements.containerElement + + const resolveScrollElement = useCallback(() => { + if (customGetScrollElement) { + return customGetScrollElement(viewportElements) + } + + return viewportElements.scrollElement + }, [customGetScrollElement, viewportElements]) + + const resolveItemKey = useCallback( + (index: number) => { + const row = centerRows[index] + + if (!row) return index + + return customGetItemKey?.(index, row) ?? row.id ?? index + }, + [centerRows, customGetItemKey] + ) + + const resolveEstimateSize = useCallback( + (index: number) => { + const row = centerRows[index] + + return row + ? (customEstimateSize?.(index, row) ?? estimateSize) + : estimateSize + }, + [centerRows, customEstimateSize, estimateSize] + ) + + const virtualizer = useVirtualizer({ + count: centerRows.length, + getScrollElement: resolveScrollElement, + getItemKey: resolveItemKey, + estimateSize: resolveEstimateSize, + overscan: customOverscan ?? overscan, + measureElement: customMeasureElement, + ...virtualizerOptionsRest, + }) as DataGridTableVirtualizerInstance + + const virtualItems = isVirtualizationEnabled + ? virtualizer.getVirtualItems() + : [] + const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0 + const measureRowRef = + isVirtualizationEnabled && customMeasureElement + ? virtualizer.measureElement + : undefined + const resolvedFetchMoreOffset = useMemo( + () => Math.max(0, fetchMoreOffset), + [fetchMoreOffset] + ) + + useEffect(() => { + if ( + !isVirtualizationEnabled || + !isInfiniteMode || + hasMore === false || + isFetchingMore + ) { + return + } + + const lastItem = virtualItems[virtualItems.length - 1] + if (!lastItem) return + + if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) { + onFetchMore?.() + } + }, [ + centerRows.length, + hasMore, + isFetchingMore, + isInfiniteMode, + isVirtualizationEnabled, + onFetchMore, + resolvedFetchMoreOffset, + virtualItems, + ]) + + return ( + + + {renderHeader && ( + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => ( + + {headerGroup.headers.map((header, hIndex) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ))} +
+ )} + + {renderHeader && + (props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + + + + {footerContent && ( + {footerContent} + )} +
+
+ ) +} + +export { DataGridTableVirtual } +export type { + DataGridTableVirtualProps, + DataGridTableVirtualScrollElements, + DataGridTableVirtualizerOptions, +} \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid-table.tsx b/apps/web/src/components/reui/data-grid/data-grid-table.tsx new file mode 100644 index 0000000..3af8892 --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid-table.tsx @@ -0,0 +1,1431 @@ +import { + type CSSProperties, + Fragment, + memo, + type MouseEvent as ReactMouseEvent, + type ReactNode, + type TouchEvent as ReactTouchEvent, + type Ref, + useCallback, + useEffect, + useMemo, + useState, +} from "react" +import { useDataGrid } from "@/components/reui/data-grid/data-grid" +import { + type Cell, + type Column, + flexRender, + type Header, + type HeaderGroup, + type Row, + type Table, +} from "@tanstack/react-table" +import { cva } from "class-variance-authority" + +import { cn } from "@cfdm/ui/lib/utils" +import { Checkbox } from "@cfdm/ui/components/checkbox" +import { Spinner } from "@cfdm/ui/components/spinner" + +const headerCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 h-8", + default: + "px-3", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +const bodyCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 py-1.5", + default: + "px-3 py-2", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +const footerCellSpacingVariants = cva("", { + variants: { + size: { + dense: + "px-2 py-1.5", + default: + "px-3 py-2", + }, + }, + defaultVariants: { + size: "default", + }, +}) + +function getPinningStyles(column: Column): CSSProperties { + const isPinned = column.getIsPinned() + + return { + left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, + right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, + position: isPinned ? "sticky" : "relative", + width: column.getSize(), + zIndex: isPinned ? 1 : 0, + } +} + +function assignRef(ref: Ref | undefined, value: T | null) { + if (!ref) return + + if (typeof ref === "function") { + ref(value) + return + } + + ;(ref as { current: T | null }).current = value +} + +type DataGridResizeStartEvent = + | ReactMouseEvent + | ReactTouchEvent + +type DataGridResizeDocumentEvent = globalThis.MouseEvent | globalThis.TouchEvent + +function isDataGridTouchEvent( + event: DataGridResizeStartEvent | DataGridResizeDocumentEvent +): event is ReactTouchEvent | globalThis.TouchEvent { + return "touches" in event +} + +function getDataGridResizeEventClientX( + event: DataGridResizeStartEvent | DataGridResizeDocumentEvent +) { + if (isDataGridTouchEvent(event)) { + return event.touches[0]?.clientX ?? event.changedTouches[0]?.clientX + } + + return event.clientX +} + +function startDataGridColumnResizeOnEnd( + event: DataGridResizeStartEvent, + header: Header, + table: Table +) { + const column = table.getColumn(header.column.id) + + if (!column || !column.getCanResize()) return + if (isDataGridTouchEvent(event) && event.touches.length > 1) return + + event.persist?.() + + const ownerDocument = event.currentTarget.ownerDocument + const previousBodyCursor = ownerDocument.body.style.cursor + const previousDocumentCursor = ownerDocument.documentElement.style.cursor + const startSize = header.getSize() + const dragStartClientX = getDataGridResizeEventClientX(event) + const headerCell = event.currentTarget.closest("th") + const headerRect = headerCell?.getBoundingClientRect() + const startOffset = + headerRect && + Number.isFinite( + table.options.columnResizeDirection === "rtl" + ? headerRect.left + : headerRect.right + ) + ? table.options.columnResizeDirection === "rtl" + ? headerRect.left + : headerRect.right + : dragStartClientX + + if (typeof dragStartClientX !== "number" || typeof startOffset !== "number") { + return + } + + ownerDocument.body.style.cursor = "col-resize" + ownerDocument.documentElement.style.cursor = "col-resize" + + const columnSizingStart = header + .getLeafHeaders() + .map( + (leafHeader) => + [leafHeader.column.id, leafHeader.column.getSize()] as [string, number] + ) + const directionMultiplier = + table.options.columnResizeDirection === "rtl" ? -1 : 1 + + const updateOffset = (clientXPos?: number, commit = false) => { + if (typeof clientXPos !== "number") return + + let nextColumnSizing: Record = {} + const deltaOffset = (clientXPos - dragStartClientX) * directionMultiplier + const deltaPercentage = Math.max(deltaOffset / startSize, -0.999999) + + columnSizingStart.forEach(([columnId, headerSize]) => { + nextColumnSizing[columnId] = + Math.round( + Math.max(headerSize + headerSize * deltaPercentage, 0) * 100 + ) / 100 + }) + + table.setColumnSizingInfo((old) => ({ + ...old, + startOffset, + startSize, + deltaOffset, + deltaPercentage, + columnSizingStart, + isResizingColumn: column.id, + })) + + if (commit) { + table.setColumnSizing((old) => ({ + ...old, + ...nextColumnSizing, + })) + } + } + + const endResize = (clientXPos?: number) => { + updateOffset(clientXPos, true) + table.setColumnSizingInfo((old) => ({ + ...old, + isResizingColumn: false, + startOffset: null, + startSize: null, + deltaOffset: null, + deltaPercentage: null, + columnSizingStart: [], + })) + ownerDocument.body.style.cursor = previousBodyCursor + ownerDocument.documentElement.style.cursor = previousDocumentCursor + } + + const mouseMoveHandler = (moveEvent: globalThis.MouseEvent) => { + updateOffset(moveEvent.clientX) + } + const mouseUpHandler = (upEvent: globalThis.MouseEvent) => { + ownerDocument.removeEventListener("mousemove", mouseMoveHandler) + ownerDocument.removeEventListener("mouseup", mouseUpHandler) + endResize(upEvent.clientX) + } + const touchMoveHandler = (moveEvent: globalThis.TouchEvent) => { + if (moveEvent.cancelable) { + moveEvent.preventDefault() + moveEvent.stopPropagation() + } + + updateOffset(getDataGridResizeEventClientX(moveEvent)) + } + const touchEndHandler = (endEvent: globalThis.TouchEvent) => { + ownerDocument.removeEventListener("touchmove", touchMoveHandler) + ownerDocument.removeEventListener("touchend", touchEndHandler) + + if (endEvent.cancelable) { + endEvent.preventDefault() + endEvent.stopPropagation() + } + + endResize(getDataGridResizeEventClientX(endEvent)) + } + + const passiveIfSupported = { passive: false } as const + + if (isDataGridTouchEvent(event)) { + ownerDocument.addEventListener( + "touchmove", + touchMoveHandler, + passiveIfSupported + ) + ownerDocument.addEventListener( + "touchend", + touchEndHandler, + passiveIfSupported + ) + } else { + ownerDocument.addEventListener( + "mousemove", + mouseMoveHandler, + passiveIfSupported + ) + ownerDocument.addEventListener( + "mouseup", + mouseUpHandler, + passiveIfSupported + ) + } + + table.setColumnSizingInfo((old) => ({ + ...old, + startOffset, + startSize, + deltaOffset: 0, + deltaPercentage: 0, + columnSizingStart, + isResizingColumn: column.id, + })) +} + +type DataGridTablePinnedBoundary = "top" | "bottom" + +function getDataGridTableRowSections( + table: Table, + rowsPinnable?: boolean +) { + if (!rowsPinnable) { + return { + topRows: [] as Row[], + centerRows: table.getRowModel().rows as Row[], + bottomRows: [] as Row[], + } + } + + return { + topRows: table.getTopRows() as Row[], + centerRows: table.getCenterRows() as Row[], + bottomRows: table.getBottomRows() as Row[], + } +} + +function getDataGridTableResolvedRows( + table: Table, + rowsPinnable?: boolean +) { + const { topRows, centerRows, bottomRows } = getDataGridTableRowSections( + table, + rowsPinnable + ) + const resolvedRows: Array<{ + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + }> = [] + + topRows.forEach((row, index) => { + resolvedRows.push({ + row, + pinnedBoundary: + index === topRows.length - 1 && + (centerRows.length > 0 || bottomRows.length > 0) + ? "top" + : undefined, + }) + }) + + centerRows.forEach((row) => { + resolvedRows.push({ row }) + }) + + bottomRows.forEach((row, index) => { + resolvedRows.push({ + row, + pinnedBoundary: + index === 0 && (centerRows.length > 0 || topRows.length > 0) + ? "bottom" + : undefined, + }) + }) + + return resolvedRows +} + +function DataGridTableFillCol() { + const { props } = useDataGrid() + + if (!props.tableLayout?.columnsResizable) return null + + return ( +
+ ) +} + +function DataGridTableFillHeadCell() { + const { props } = useDataGrid() + + if (!props.tableLayout?.columnsResizable) return null + + return ( + + {children} + + ) +} + +function DataGridTableHeadRow({ + children, + headerGroup, +}: { + children: ReactNode + headerGroup: HeaderGroup +}) { + const { props } = useDataGrid() + + return ( + th]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && "bg-transparent", + props.tableLayout?.headerBackground === false && "bg-transparent", + props.tableClassNames?.headerRow + )} + > + {children} + + + ) +} + +function DataGridTableHeadRowCell({ + children, + header, + dndRef, + dndStyle, +}: { + children: ReactNode + header: Header + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props } = useDataGrid() + + const { column } = header + const isPinned = column.getIsPinned() + const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinned = + isPinned === "right" && column.getIsFirstColumn("right") + const isLastVisibleColumn = + column.getIndex() === + header.getContext().table.getVisibleLeafColumns().length - 1 + const headerCellSpacing = headerCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableHeadRowCellResize({ + header, +}: { + header: Header +}) { + const { props, table } = useDataGrid() + const { column } = header + const isLastVisibleColumn = + column.getIndex() === + header.getContext().table.getVisibleLeafColumns().length - 1 + const isResizeModeOnEnd = + (props.tableLayout?.columnsResizeMode ?? table.options.columnResizeMode) === + "onEnd" + + const handleMouseDown = (event: ReactMouseEvent) => { + event.preventDefault() + event.stopPropagation() + + if (isResizeModeOnEnd) { + startDataGridColumnResizeOnEnd(event, header, table) + return + } + + header.getResizeHandler()(event) + } + + const handleTouchStart = (event: ReactTouchEvent) => { + event.preventDefault() + event.stopPropagation() + + if (isResizeModeOnEnd) { + startDataGridColumnResizeOnEnd(event, header, table) + return + } + + header.getResizeHandler()(event) + } + + return ( +
column.resetSize(), + onMouseDown: handleMouseDown, + onTouchStart: handleTouchStart, + className: cn( + "absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex", + isLastVisibleColumn + ? "end-0 w-5 justify-end before:hidden" + : "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border", + column.getIsResizing() && + (isResizeModeOnEnd + ? "opacity-100" + : isLastVisibleColumn + ? "before:absolute before:end-0 before:block before:inset-y-0 before:w-0.5 before:bg-primary opacity-100" + : "before:block before:bg-primary before:w-0.5 opacity-100") + ), + }} + /> + ) +} + +function DataGridTableResizeIndicator({ + viewportElement, +}: { + viewportElement: HTMLDivElement | null +}) { + const { props, table } = useDataGrid() + const columnSizingInfo = table.getState().columnSizingInfo + const resizingColumnId = columnSizingInfo.isResizingColumn + const resizeMode = + props.tableLayout?.columnsResizeMode ?? table.options.columnResizeMode + + if ( + !props.tableLayout?.columnsResizable || + resizeMode !== "onEnd" || + !resizingColumnId + ) { + return null + } + + const resizingHeader = table + .getFlatHeaders() + .find( + (header) => + header.column.id === resizingColumnId || header.id === resizingColumnId + ) + + if (!resizingHeader) return null + + const deltaOffset = columnSizingInfo.deltaOffset ?? 0 + const headerHeight = + viewportElement + ?.querySelector('[data-slot="data-grid-table"] thead') + ?.getBoundingClientRect().height ?? 0 + const indicatorLeft = + typeof columnSizingInfo.startOffset === "number" && viewportElement + ? columnSizingInfo.startOffset - + viewportElement.getBoundingClientRect().left + : resizingHeader.getStart() + resizingHeader.getSize() + + return ( +
+} + +function DataGridTableBody({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + + return ( + + {children} + + ) +} + +function DataGridTableFoot({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + return ( + + {children} + + ) +} + +function DataGridTableFootRow({ children }: { children: ReactNode }) { + const { props } = useDataGrid() + return ( + + {children} + + + ) +} + +function DataGridTableFootRowCell({ + children, + colSpan, + className, +}: { + children?: ReactNode + colSpan?: number + className?: string +}) { + const { props } = useDataGrid() + const spacing = footerCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + return ( + + ) +} + +function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) { + const { table, props } = useDataGrid() + + return ( + td]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && + "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent", + table.options.enableRowSelection && "*:first:relative", + props.tableClassNames?.bodyRow + )} + > + {children} + + + ) +} + +function DataGridTableBodyRowSkeletonCell({ + children, + column, +}: { + children: ReactNode + column: Column +}) { + const { props, table } = useDataGrid() + const bodyCellSpacing = bodyCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableBodyRow({ + children, + row, + pinnedBoundary, + rowRef, + dndRef, + dndStyle, +}: { + children: ReactNode + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + rowRef?: React.Ref + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props, table } = useDataGrid() + const isRowPinned = row.getIsPinned() + + return ( + { + assignRef(rowRef, node) + assignRef(dndRef, node) + }} + style={{ ...(dndStyle ? dndStyle : null) }} + data-state={ + table.options.enableRowSelection && row.getIsSelected() + ? "selected" + : undefined + } + data-row-pinned={isRowPinned || undefined} + data-row-pinned-boundary={pinnedBoundary} + onClick={() => props.onRowClick && props.onRowClick(row.original)} + className={cn( + "hover:bg-muted/40 data-[state=selected]:bg-muted/50", + props.onRowClick && "cursor-pointer", + !props.tableLayout?.stripped && + props.tableLayout?.rowBorder && + "border-border border-b [&:not(:last-child)>td]:border-b", + props.tableLayout?.cellBorder && "*:last:border-e-0", + props.tableLayout?.stripped && + "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent", + table.options.enableRowSelection && "*:first:relative", + props.tableLayout?.rowsPinnable && + isRowPinned && + "bg-muted/30 hover:bg-muted/50", + pinnedBoundary === "top" && "[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)]", + pinnedBoundary === "bottom" && + "[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)]", + props.tableClassNames?.bodyRow + )} + > + {children} + + + ) +} + +function DataGridTableBodyRowExpandded({ row }: { row: Row }) { + const { props, table } = useDataGrid() + + return ( + td]:border-b" + )} + > + + + ) +} + +function DataGridTableBodyRowCell({ + children, + cell, + dndRef, + dndStyle, +}: { + children: ReactNode + cell: Cell + dndRef?: React.Ref + dndStyle?: CSSProperties +}) { + const { props } = useDataGrid() + + const { column, row } = cell + const isPinned = column.getIsPinned() + const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinned = + isPinned === "right" && column.getIsFirstColumn("right") + const bodyCellSpacing = bodyCellSpacingVariants({ + size: props.tableLayout?.dense ? "dense" : "default", + }) + + return ( + + ) +} + +function DataGridTableRenderedRow({ + row, + pinnedBoundary, + rowRef, +}: { + row: Row + pinnedBoundary?: DataGridTablePinnedBoundary + rowRef?: React.Ref +}) { + return ( + + + {row.getVisibleCells().map((cell: Cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + {row.getIsExpanded() && } + + ) +} + +function DataGridTableEmpty() { + const { table, props } = useDataGrid() + const visibleColumnCount = + table.getVisibleLeafColumns().length + + (props.tableLayout?.columnsResizable ? 1 : 0) + + return ( + + + + ) +} + +function DataGridTableLoader() { + const { props } = useDataGrid() + + return ( +
+
+ + {props.loadingMessage || "Loading..."} +
+
+ ) +} + +function DataGridTableRowPin({ row }: { row: Row }) { + const isPinned = row.getIsPinned() + + return ( + + ) +} + +function DataGridTableRowSelect({ row }: { row: Row }) { + return ( + <> + + row.toggleSelected(!!value)} + aria-label="Select row" + className="align-[inherit]" + /> + + ) +} + +function DataGridTableRowSelectAll() { + const { table, recordCount, isLoading } = useDataGrid() + + const isAllSelected = table.getIsAllPageRowsSelected() + const isSomeSelected = table.getIsSomePageRowsSelected() + + return ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="align-[inherit]" + /> + ) +} + +function DataGridTableBodyRows({ table }: { table: Table }) { + const { isLoading, props } = useDataGrid() + const pagination = table.getState().pagination + + if (isLoading && props.loadingMode === "skeleton" && pagination?.pageSize) { + return ( + <> + {Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( + + {table.getVisibleFlatColumns().map((column, colIndex) => ( + + {column.columnDef.meta?.skeleton} + + ))} + + ))} + + ) + } + + if (isLoading && props.loadingMode === "spinner") { + return ( + + + + ) + } + + const resolvedRows = getDataGridTableResolvedRows( + table, + props.tableLayout?.rowsPinnable + ) + + if (!resolvedRows.length) return + + return ( + <> + {resolvedRows.map(({ row, pinnedBoundary }) => ( + + ))} + + ) +} + +/** + * Memoized body rows: skip re-renders during active column resize. + * Column widths update via CSS variables on the
+ {children} +
+ {children} +
+ {children} +
+ {table + .getAllColumns() + .find((column) => column.columnDef.meta?.expandedContent) + ?.columnDef.meta?.expandedContent?.(row.original)} +
+ {children} +
+ {props.emptyMessage || "No data available"} +
+
+ + + + + {props.loadingMessage || "Loading..."} +
+
element, + * so the browser handles width changes without React re-renders. + */ +const MemoizedDataGridTableBodyRows = memo( + DataGridTableBodyRows, + (_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn +) as typeof DataGridTableBodyRows + +function DataGridTableHeader() { + const { table, props } = useDataGrid() + + return ( + + + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+
+
+ ) +} + +function DataGridTable({ + footerContent, + renderHeader = true, +}: { + footerContent?: ReactNode + renderHeader?: boolean +}) { + const { table, props } = useDataGrid() + + return ( + + + {renderHeader && ( + + {table + .getHeaderGroups() + .map((headerGroup: HeaderGroup, index) => { + return ( + + {headerGroup.headers.map((header, index) => { + const { column } = header + + return ( + + {header.isPlaceholder ? null : props.tableLayout + ?.columnsResizable && column.getCanResize() ? ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} + {props.tableLayout?.columnsResizable && + column.getCanResize() && ( + + )} +
+ ) + })} +
+ ) + })} +
+ )} + + {renderHeader && + (props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( + + )} + + + + + + {footerContent && ( + {footerContent} + )} +
+
+ ) +} + +export { + DataGridTable, + DataGridTableBase, + DataGridTableBody, + DataGridTableBodyRow, + DataGridTableBodyRowCell, + DataGridTableBodyRowExpandded, + DataGridTableRenderedRow, + DataGridTableBodyRowSkeleton, + DataGridTableBodyRowSkeletonCell, + DataGridTableEmpty, + DataGridTableFoot, + DataGridTableFootRow, + DataGridTableFootRowCell, + DataGridTableHeader, + DataGridTableHead, + DataGridTableHeadRow, + DataGridTableHeadRowCell, + DataGridTableHeadRowCellResize, + DataGridTableLoader, + DataGridTableRowPin, + DataGridTableRowSelect, + DataGridTableRowSelectAll, + DataGridTableRowSpacer, + DataGridTableViewport, + getDataGridTableResolvedRows, + getDataGridTableRowSections, +} + +export type { DataGridTablePinnedBoundary } \ No newline at end of file diff --git a/apps/web/src/components/reui/data-grid/data-grid.tsx b/apps/web/src/components/reui/data-grid/data-grid.tsx new file mode 100644 index 0000000..e2b5dfc --- /dev/null +++ b/apps/web/src/components/reui/data-grid/data-grid.tsx @@ -0,0 +1,270 @@ +"use client" + +import { createContext, type ReactNode, useContext, useMemo } from "react" +import { + type Column, + type ColumnFiltersState, + type RowData, + type SortingState, + type Table, +} from "@tanstack/react-table" + +import { cn } from "@cfdm/ui/lib/utils" + +declare module "@tanstack/react-table" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + headerTitle?: string + headerClassName?: string + cellClassName?: string + skeleton?: ReactNode + expandedContent?: (row: TData) => ReactNode + } +} + +/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */ +export function getColumnHeaderLabel( + column: Column +): string { + const meta = column.columnDef.meta as { headerTitle?: string } | undefined + if (typeof meta?.headerTitle === "string") return meta.headerTitle + const defHeader = column.columnDef.header + if (typeof defHeader === "string") return defHeader + return String(column.id) +} + +export type DataGridApiFetchParams = { + pageIndex: number + pageSize: number + sorting?: SortingState + filters?: ColumnFiltersState + searchQuery?: string +} + +export type DataGridApiResponse = { + data: T[] + empty: boolean + pagination: { + total: number + page: number + } +} + +export interface DataGridContextProps { + props: DataGridProps + table: Table + recordCount: number + isLoading: boolean +} + +export type DataGridRequestParams = { + pageIndex: number + pageSize: number + sorting?: SortingState + columnFilters?: ColumnFiltersState +} + +export interface DataGridProps { + className?: string + table?: Table + recordCount: number + children?: ReactNode + onRowClick?: (row: TData) => void + isLoading?: boolean + loadingMode?: "skeleton" | "spinner" + loadingMessage?: ReactNode | string + fetchingMoreMessage?: ReactNode | string + allRowsLoadedMessage?: ReactNode | string + emptyMessage?: ReactNode | string + tableLayout?: { + dense?: boolean + cellBorder?: boolean + rowBorder?: boolean + rowRounded?: boolean + stripped?: boolean + headerBackground?: boolean + headerBorder?: boolean + headerSticky?: boolean + width?: "auto" | "fixed" + columnsVisibility?: boolean + columnsResizable?: boolean + columnsResizeMode?: "onChange" | "onEnd" + columnsPinnable?: boolean + columnsMovable?: boolean + columnsDraggable?: boolean + rowsDraggable?: boolean + rowsPinnable?: boolean + } + tableClassNames?: { + base?: string + header?: string + headerRow?: string + headerSticky?: string + body?: string + bodyRow?: string + footer?: string + edgeCell?: string + } +} + +const DataGridContext = createContext< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + DataGridContextProps | undefined +>(undefined) + +function useDataGrid() { + const context = useContext(DataGridContext) + if (!context) { + throw new Error("useDataGrid must be used within a DataGridProvider") + } + return context +} + +function DataGridProvider({ + children, + table, + ...props +}: DataGridProps & { table: Table }) { + const tableState = table.getState() + const resolvedColumnsResizeMode = + props.tableLayout?.columnsResizeMode ?? "onEnd" + + // Keep resize mode aligned with the DataGrid contract every render so + // consumer-level useReactTable options cannot flip it back between drags. + if (props.tableLayout?.columnsResizable) { + table.options.columnResizeMode = resolvedColumnsResizeMode + } + + // Memoize context value so consumers don't re-render during column resize. + // Column sizing state is intentionally excluded from deps -- CSS variables + // on the
element handle width updates without React re-renders. + const value = useMemo( + () => ({ + props, + table, + recordCount: props.recordCount, + isLoading: props.isLoading || false, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + table, + props.recordCount, + props.isLoading, + props.loadingMode, + props.loadingMessage, + props.fetchingMoreMessage, + props.allRowsLoadedMessage, + props.emptyMessage, + props.onRowClick, + props.className, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(props.tableLayout), + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(props.tableClassNames), + tableState.sorting, + tableState.pagination, + tableState.columnFilters, + tableState.rowSelection, + tableState.expanded, + tableState.columnVisibility, + tableState.columnOrder, + tableState.columnPinning, + tableState.globalFilter, + ] + ) + + return ( + + {children} + + ) +} + +function DataGrid({ + children, + table, + ...props +}: DataGridProps) { + const defaultProps: Partial> = { + loadingMode: "skeleton", + tableLayout: { + dense: false, + cellBorder: false, + rowBorder: true, + rowRounded: false, + stripped: false, + headerSticky: false, + headerBackground: true, + headerBorder: true, + width: "fixed", + columnsVisibility: false, + columnsResizable: false, + columnsResizeMode: "onEnd", + columnsPinnable: false, + columnsMovable: false, + columnsDraggable: false, + rowsDraggable: false, + rowsPinnable: false, + }, + tableClassNames: { + base: "", + header: "", + headerRow: "", + headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs", + body: "", + bodyRow: "", + footer: "", + edgeCell: "", + }, + } + + const mergedProps: DataGridProps = { + ...defaultProps, + ...props, + tableLayout: { + ...defaultProps.tableLayout, + ...(props.tableLayout || {}), + }, + tableClassNames: { + ...defaultProps.tableClassNames, + ...(props.tableClassNames || {}), + }, + } + + // Ensure table is provided + if (!table) { + throw new Error('DataGrid requires a "table" prop') + } + + return ( + + {children} + + ) +} + +function DataGridContainer({ + children, + className, + border = true, +}: { + children: ReactNode + className?: string + border?: boolean +}) { + return ( +
+ {children} +
+ ) +} + +export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer } \ No newline at end of file diff --git a/apps/web/src/routes/_auth/accounts.tsx b/apps/web/src/routes/_auth/accounts.tsx index 3ab56c8..508219b 100644 --- a/apps/web/src/routes/_auth/accounts.tsx +++ b/apps/web/src/routes/_auth/accounts.tsx @@ -10,7 +10,8 @@ import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' import { Button } from '@cfdm/ui/components/button' import { Badge } from '@cfdm/ui/components/badge' -import { DataTableCard, type DataTableColumn } from '@/components/data-table-card' +import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card' +import type { DataTableColumn } from '@/components/data-table-card' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' import { ConfirmDialog } from '@/components/confirm-dialog' @@ -178,7 +179,7 @@ function AccountsPage() { emptyTitle="Аккаунты не найдены" emptyAction={} > - {(snap) => a.id} />} + {(snap) => a.id} pinLastColumn />} - r.id} + rowId={(r) => r.id} emptyTitle="Записей нет" emptyAction={} + pinLastColumn + footerContent={ +
+ Приходы: {formatCurrency(totalCredit, snap.settings[0]?.baseCurrency ?? 'RUB')} + Списания: {formatCurrency(totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB')} + Итого: {formatCurrency(totalCredit - totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB')} +
+ } /> )} diff --git a/apps/web/src/routes/_auth/dashboard.tsx b/apps/web/src/routes/_auth/dashboard.tsx index 71e2dce..e4d7bd9 100644 --- a/apps/web/src/routes/_auth/dashboard.tsx +++ b/apps/web/src/routes/_auth/dashboard.tsx @@ -1,25 +1,17 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { ServerIcon, AlertTriangleIcon, WalletIcon, TrendingUpIcon } from 'lucide-react' +import type { ColumnDef } from '@tanstack/react-table' import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot' import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' import { SectionCards } from '@/components/section-cards' import { QueryState } from '@/components/query-state' -import { TableCard } from '@/components/table-card' +import { DataGridCard } from '@/components/data-grid-card' import { SectionCardsSkeleton } from '@/components/skeletons' -import { EmptyState } from '@/components/empty-state' import { Button } from '@cfdm/ui/components/button' import { Badge } from '@cfdm/ui/components/badge' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@cfdm/ui/components/table' import { computeInventoryHealth } from '@/lib/inventory-health' import { formatInBaseCurrency, normalizeRatesPayload } from '@/lib/format' @@ -93,7 +85,7 @@ function DashboardPage() { ]} /> - всё ок ) } - > - {issues.length === 0 ? ( -
- } - title="Проблем не найдено" - description="Все активные VPS имеют проект, ставку и актуальный синк" - /> -
- ) : ( -
- - - Проблема - Кол-во - Действие - - - - {issues.map((issue) => ( - - -
- -
- {issue.title} - {issue.hint ? ( - {issue.hint} - ) : null} -
-
-
- {issue.count} - - - -
- ))} -
-
- )} - + columns={[ + { + id: 'title', + header: 'Проблема', + cell: ({ row }) => ( +
+ +
+ {row.original.title} + {row.original.hint ? ( + {row.original.hint} + ) : null} +
+
+ ), + }, + { + id: 'count', + header: 'Кол-во', + cell: ({ row }) => {row.original.count}, + meta: { align: 'right' }, + }, + { + id: 'action', + header: 'Действие', + cell: ({ row }) => ( + + ), + }, + ] as ColumnDef<{ key: string; title: string; count: number; to: string; hint?: string }>[]} + data={issues} + rowId={(i) => i.key} + emptyTitle="Проблем не найдено" + emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк" + /> - - - - - IP / DNS - Проект - Статус - Ставка/мес - - - - {activeVps.slice(0, 8).map((v) => ( - - {v.ip || v.dns} - {v.project || '—'} - - - {vpsStatusLabel(v.status)} - - - - {formatInBaseCurrency( - v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0), - v.currency, - snap.settings, - ratesData, - )} - - - ))} - -
-
+ {row.original.ip || row.original.dns}, + }, + { + id: 'project', + header: 'Проект', + cell: ({ row }) => {row.original.project || '—'}, + }, + { + id: 'status', + header: 'Статус', + cell: ({ row }) => ( + + {vpsStatusLabel(row.original.status)} + + ), + }, + { + id: 'rate', + header: 'Ставка/мес', + cell: ({ row }) => ( + + {formatInBaseCurrency( + row.original.tariffType === 'daily' + ? Number(row.original.dailyRate || 0) * 30 + : Number(row.original.monthlyRate || 0), + row.original.currency, + snap.settings, + ratesData, + )} + + ), + }, + ] as ColumnDef[]} + data={activeVps.slice(0, 8)} + rowId={(v) => v.id} + pagination={false} + /> ) }} diff --git a/apps/web/src/routes/_auth/payments.tsx b/apps/web/src/routes/_auth/payments.tsx index f29d04d..0db0ad9 100644 --- a/apps/web/src/routes/_auth/payments.tsx +++ b/apps/web/src/routes/_auth/payments.tsx @@ -9,7 +9,8 @@ import { api, ApiError } from '@/lib/api-client' import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' import { Button } from '@cfdm/ui/components/button' -import { DataTableCard, type DataTableColumn } from '@/components/data-table-card' +import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card' +import type { DataTableColumn } from '@/components/data-table-card' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' import { ConfirmDialog } from '@/components/confirm-dialog' @@ -123,6 +124,12 @@ function PaymentsPage() { const sorted = [...(snapshot?.payments ?? [])].sort((a, b) => b.date.localeCompare(a.date)) + const totalByCurrency = sorted.reduce>((acc, p) => { + const cur = p.currency ?? 'RUB' + acc[cur] = (acc[cur] ?? 0) + Number(p.amount || 0) + return acc + }, {}) + return ( Добавить платёж} > - {() => p.id} />} + {() => ( + p.id} + pinLastColumn + virtualization={sorted.length > 200} + height={560} + footerContent={ +
+ {Object.entries(totalByCurrency).map(([cur, sum]) => ( + Итого {cur}: {formatCurrency(sum, cur)} + ))} +
+ } + /> + )} Добавить хостера} > - {(snap) => p.id} />} + {(snap) => p.id} pinLastColumn />} } title="Нет тарифов" />} > - {(snap) => t.id} />} + {(snap) => t.id} />}
) diff --git a/apps/web/src/routes/_auth/vps.tsx b/apps/web/src/routes/_auth/vps.tsx index 20941c6..c13eb8a 100644 --- a/apps/web/src/routes/_auth/vps.tsx +++ b/apps/web/src/routes/_auth/vps.tsx @@ -14,7 +14,8 @@ import { PageShell } from '@/components/page-shell' import { PageHeader } from '@/components/page-header' import { Button } from '@cfdm/ui/components/button' import { Badge } from '@cfdm/ui/components/badge' -import { DataTableCard, type DataTableColumn } from '@/components/data-table-card' +import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card' +import type { DataTableColumn } from '@/components/data-table-card' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' import { ConfirmDialog } from '@/components/confirm-dialog' @@ -333,13 +334,16 @@ function VpsPage() { cityOptions={cityOptions} /> {tableSections.map((section) => ( - v.id} + rowId={(v) => v.id} emptyTitle="VPS не найдены" + pinLastColumn + virtualization={section.items.length > 200} + height={560} /> ))}
diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 89fd101..093235f 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { Select as SelectPrimitive } from "@base-ui/react/select" @@ -59,9 +61,9 @@ function SelectContent({ children, side = "bottom", sideOffset = 4, - align = "start", + align = "center", alignOffset = 0, - alignItemWithTrigger = false, + alignItemWithTrigger = true, ...props }: SelectPrimitive.Popup.Props & Pick< diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx new file mode 100644 index 0000000..851255c --- /dev/null +++ b/packages/ui/src/components/spinner.tsx @@ -0,0 +1,10 @@ +import { cn } from "@cfdm/ui/lib/utils" +import { Loader2Icon } from "lucide-react" + +function Spinner({ className, ...props }: React.ComponentProps<"svg">) { + return ( + + ) +} + +export { Spinner } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 284eaa4..602d5b3 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -39,6 +39,15 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + --destructive-foreground: var(--color-red-800); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-900); + --info: var(--color-violet-500); + --info-foreground: var(--color-violet-900); + --warning: var(--color-yellow-500); + --warning-foreground: var(--color-yellow-900); + --invert: var(--color-zinc-900); + --invert-foreground: var(--color-zinc-50); } .dark { @@ -73,6 +82,15 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); + --destructive-foreground: var(--color-red-600); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-600); + --info: var(--color-violet-500); + --info-foreground: var(--color-violet-600); + --warning: var(--color-yellow-500); + --warning-foreground: var(--color-yellow-600); + --invert: var(--color-zinc-700); + --invert-foreground: var(--color-zinc-50); } @theme inline { @@ -111,6 +129,15 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --color-invert-foreground: var(--invert-foreground); + --color-invert: var(--invert); + --color-warning-foreground: var(--warning-foreground); + --color-warning: var(--warning); + --color-info-foreground: var(--info-foreground); + --color-info: var(--info); + --color-success-foreground: var(--success-foreground); + --color-success: var(--success); + --color-destructive-foreground: var(--destructive-foreground); } @layer base { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24a97d1..e36e1c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,18 @@ importers: '@cfdm/ui': specifier: workspace:* version: link:../../packages/ui + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.7) '@hookform/resolvers': specifier: ^3.10.0 version: 3.10.0(react-hook-form@7.80.0(react@19.2.7)) @@ -108,6 +120,12 @@ importers: '@tanstack/react-router-devtools': specifier: ^1.130.2 version: 1.167.0(@tanstack/react-router@1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.13)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': + specifier: ^3.14.4 + version: 3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -369,6 +387,34 @@ packages: '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1416,6 +1462,19 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/react-virtual@3.14.4': + resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.171.13': resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==} engines: {node: '>=20.19'} @@ -1462,6 +1521,13 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + + '@tanstack/virtual-core@3.17.2': + resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==} + '@tanstack/virtual-file-routes@1.162.0': resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} @@ -3518,6 +3584,38 @@ snapshots: '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + '@drizzle-team/brocli@0.10.2': {} '@esbuild-kit/core-utils@3.3.2': @@ -4257,6 +4355,18 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) + '@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/react-virtual@3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@tanstack/router-core@1.171.13': dependencies: '@tanstack/history': 1.162.0 @@ -4324,6 +4434,10 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tanstack/table-core@8.21.3': {} + + '@tanstack/virtual-core@3.17.2': {} + '@tanstack/virtual-file-routes@1.162.0': {} '@types/babel__core@7.20.5':