feat(web): миграция таблиц на @reui/data-grid (TanStack Table + virtualization + pinning)
- Установлен @reui/data-grid через shadcn CLI: зависимости @tanstack/react-table, @tanstack/react-virtual, @dnd-kit/* добавлены в apps/web - Поправлены type-only импорты во всех файлах data-grid (verbatimModuleSyntax), убран unused параметр table в DataGridTableVirtualBody - Создана shared-обёртка apps/web/src/components/data-grid-card.tsx (замена DataTableCard) с типизированными ColumnDef<TData>, сортировкой, пагинацией (>25 строк), column pinning, optional virtualization (DataGridScrollArea + DataGridTableVirtual), footer-контентом и хелпером columnDefFromDataTable - Мигрированы все 7 страниц: vps, dashboard, payments, balance, accounts, tariffs, providers — DataTableCard/TableCard+Table → DataGridCard - Virtualization включена для VPS и payments при >200 строк (height 560) - Column pinning последней колонки (actions) — vps, payments, balance, accounts, providers - Footer rows с итогами: balance (приходы/списания/итого), payments (по валютам) - build + tsc --noEmit без ошибок Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<TData extends object> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
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<TData extends object>({
|
||||
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<TData>) {
|
||||
const [sorting, setSorting] = useState<SortingState>(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<TData>({
|
||||
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 (
|
||||
<Card className={className}>
|
||||
{(title || actions) && (
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className="p-4">
|
||||
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
{(title || actions) && (
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
)}
|
||||
<CardContent className="p-0">
|
||||
<DataGridContainer className="border-0 rounded-none">
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
onRowClick={onRowClick}
|
||||
emptyMessage={emptyTitle}
|
||||
tableLayout={{
|
||||
dense,
|
||||
rowBorder: true,
|
||||
headerSticky: true,
|
||||
headerBackground: true,
|
||||
headerBorder: true,
|
||||
width: 'auto',
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsPinnable: false,
|
||||
columnsMovable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
}}
|
||||
>
|
||||
{virtualization ? (
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<>
|
||||
<DataGridTable footerContent={footerContent} />
|
||||
{showPagination ? <DataGridPagination /> : null}
|
||||
</>
|
||||
)}
|
||||
</DataGrid>
|
||||
</DataGridContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** Хелпер для конвертации старых DataTableColumn<T> → ColumnDef<T>. */
|
||||
export function columnDefFromDataTable<T>(
|
||||
cols: {
|
||||
key: string
|
||||
header: ReactNode
|
||||
cell: (row: T, index: number) => ReactNode
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
}[],
|
||||
): ColumnDef<T, unknown>[] {
|
||||
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 }
|
||||
@@ -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<typeof badgeVariants>["variant"]
|
||||
size?: VariantProps<typeof badgeVariants>["size"]
|
||||
radius?: VariantProps<typeof badgeVariants>["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 }
|
||||
@@ -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<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
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 (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<CirclePlusIcon className="size-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={title}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
onClick={() => 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
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
||||
@@ -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<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
/** 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<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
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" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
))
|
||||
|
||||
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(
|
||||
<DropdownMenuGroup key="group-filter">
|
||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Sort section
|
||||
if (canSort) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="sort-asc"
|
||||
onClick={() => {
|
||||
if (isSorted === "asc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="sort-desc"
|
||||
onClick={() => {
|
||||
if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(true)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Pin section
|
||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="pin-left"
|
||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
{isPinned === "left" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="pin-right"
|
||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
{isPinned === "right" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Move section
|
||||
if (props.tableLayout?.columnsMovable) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
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}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
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}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Visibility section
|
||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide())
|
||||
.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.id}
|
||||
checked={col.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||
className="capitalize"
|
||||
>
|
||||
{getColumnHeaderLabel(col)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent className="w-40" align="start">
|
||||
{menuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={headerLabelClassName}>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeader = memo(
|
||||
DataGridColumnHeaderInner
|
||||
) as typeof DataGridColumnHeaderInner
|
||||
|
||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||
@@ -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<TData>({
|
||||
table,
|
||||
trigger,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{getColumnHeaderLabel(column)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnVisibility }
|
||||
@@ -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<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesLabel: "Show",
|
||||
sizesDescription: "per page",
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
more: false,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
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(
|
||||
<Button
|
||||
key={i}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||
"bg-accent text-accent-foreground": pageIndex === i,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (pageIndex !== i) {
|
||||
table.setPageIndex(i)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
// Render a "previous" ellipsis button if there are previous pages to show
|
||||
const renderEllipsisPrevButton = () => {
|
||||
if (currentGroupStart > 0) {
|
||||
return (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
||||
const renderEllipsisNextButton = () => {
|
||||
if (currentGroupEnd < pageCount) {
|
||||
return (
|
||||
<Button
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps?.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-14" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{mergedProps?.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{mergedProps.previousPageLabel}
|
||||
</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{renderEllipsisPrevButton()}
|
||||
|
||||
{renderPageButtons()}
|
||||
|
||||
{renderEllipsisNextButton()}
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridPagination, type DataGridPaginationProps }
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
startScrollTop: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
||||
const observedElementsRef = useRef<ObservedElements>({
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
clearDragState()
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div ref={containerRef} className="relative">
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="data-grid-scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
{showHorizontal && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
{showVertical && !usesCustomVerticalScrollbar && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
</ScrollAreaPrimitive.Root>
|
||||
|
||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-2",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full"
|
||||
)}
|
||||
onLostPointerCapture={clearDragState}
|
||||
onPointerCancel={handleThumbPointerUp}
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
onPointerMove={handleThumbPointerMove}
|
||||
onPointerUp={handleThumbPointerUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridScrollArea }
|
||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||
@@ -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<typeof useSortable>
|
||||
const SortableRowContext = createContext<Pick<
|
||||
SortableContextValue,
|
||||
"attributes" | "listeners"
|
||||
> | 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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
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 (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRows<TData>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const tableContainerRef = useRef<HTMLDivElement>(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 (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingRow(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingRow(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={tableContainerRef}
|
||||
className={
|
||||
isDraggingRow
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
@@ -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<TData>({
|
||||
header,
|
||||
}: {
|
||||
header: Header<TData, unknown>
|
||||
}) {
|
||||
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 (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
dndStyle={style}
|
||||
dndRef={setNodeRef}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-0.5">
|
||||
{canOrder && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</div>
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||
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 (
|
||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDnd<TData>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={[restrictToTableBounds]}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingColumn(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={containerRef}
|
||||
className={
|
||||
isDraggingColumn
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDnd }
|
||||
@@ -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<TData> = Omit<
|
||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||
> & {
|
||||
estimateSize?: (index: number, row: Row<TData>) => number
|
||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
||||
getScrollElement?: (
|
||||
elements: DataGridTableVirtualScrollElements
|
||||
) => HTMLElement | null
|
||||
}
|
||||
|
||||
interface DataGridTableVirtualProps<TData> {
|
||||
height?: number | string
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
isFetchingMore?: boolean
|
||||
hasMore?: boolean
|
||||
fetchMoreOffset?: number
|
||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||
}
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
columnCount: number
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
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 (
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow({
|
||||
children,
|
||||
className,
|
||||
columnCount,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columnCount: number
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table: _table,
|
||||
columnCount,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
virtualItems,
|
||||
totalSize,
|
||||
isVirtualizationEnabled,
|
||||
isInfiniteMode,
|
||||
isFetchingMore,
|
||||
hasMore,
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
void _table
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
|
||||
if (!totalRows) return <DataGridTableEmpty />
|
||||
|
||||
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(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (isVirtualizationEnabled) {
|
||||
if (leadingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
columnCount={columnCount}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
virtualItems.forEach((virtualRow) => {
|
||||
const row = centerRows[virtualRow.index]
|
||||
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (trailingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
columnCount={columnCount}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
centerRows.forEach((row) => {
|
||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||
})
|
||||
}
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-loading"
|
||||
columnCount={columnCount}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
</div>
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (showCompleteRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
columnCount={columnCount}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
bottomRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||
? "bottom"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <>{renderedRows}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized virtual body: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> 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<TData>({
|
||||
height,
|
||||
estimateSize = 48,
|
||||
overscan = 10,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
isFetchingMore = false,
|
||||
hasMore,
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
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<DataGridTableVirtualScrollElements>({
|
||||
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 (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: { height, overflow: "auto", position: "relative" }
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, hIndex) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
columnCount={columnCount}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
virtualItems={virtualItems}
|
||||
totalSize={totalSize}
|
||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
||||
isInfiniteMode={isInfiniteMode}
|
||||
isFetchingMore={isFetchingMore}
|
||||
hasMore={hasMore}
|
||||
loadingMoreMessage={loadingMoreMessage}
|
||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||
measureRowRef={measureRowRef}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableVirtual }
|
||||
export type {
|
||||
DataGridTableVirtualProps,
|
||||
DataGridTableVirtualScrollElements,
|
||||
DataGridTableVirtualizerOptions,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<TData extends RowData, TValue> {
|
||||
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<TData, TValue>(
|
||||
column: Column<TData, TValue>
|
||||
): 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<T> = {
|
||||
data: T[]
|
||||
empty: boolean
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DataGridContextProps<TData extends object> {
|
||||
props: DataGridProps<TData>
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export type DataGridRequestParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
columnFilters?: ColumnFiltersState
|
||||
}
|
||||
|
||||
export interface DataGridProps<TData extends object> {
|
||||
className?: string
|
||||
table?: Table<TData>
|
||||
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<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
function useDataGrid() {
|
||||
const context = useContext(DataGridContext)
|
||||
if (!context) {
|
||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function DataGridProvider<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
||||
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 <table> 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 (
|
||||
<DataGridContext.Provider value={value}>
|
||||
{children}
|
||||
</DataGridContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGrid<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData>) {
|
||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
||||
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<TData> = {
|
||||
...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 (
|
||||
<DataGridProvider table={table} {...mergedProps}>
|
||||
{children}
|
||||
</DataGridProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridContainer({
|
||||
children,
|
||||
className,
|
||||
border = true,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
border?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
border &&
|
||||
"border-border rounded-lg border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||
@@ -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={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить аккаунт</Button>}
|
||||
>
|
||||
{(snap) => <DataTableCard columns={columns} data={snap.providerAccounts} rowKey={(a) => a.id} />}
|
||||
{(snap) => <DataGridCard columns={columnDefFromDataTable(columns)} data={snap.providerAccounts} rowId={(a) => a.id} pinLastColumn />}
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
|
||||
@@ -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 { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { SectionCards } from '@/components/section-cards'
|
||||
@@ -146,12 +147,20 @@ function BalancePage() {
|
||||
{ label: 'Чистый баланс (ledger)', value: formatCurrency(totalCredit - totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB') },
|
||||
]}
|
||||
/>
|
||||
<DataTableCard
|
||||
columns={columns}
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={rows}
|
||||
rowKey={(r) => r.id}
|
||||
rowId={(r) => r.id}
|
||||
emptyTitle="Записей нет"
|
||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить</Button>}
|
||||
pinLastColumn
|
||||
footerContent={
|
||||
<div className="flex justify-end gap-6 px-3 py-2 text-sm tabular-nums">
|
||||
<span>Приходы: <b className="text-foreground">{formatCurrency(totalCredit, snap.settings[0]?.baseCurrency ?? 'RUB')}</b></span>
|
||||
<span>Списания: <b className="text-foreground">{formatCurrency(totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB')}</b></span>
|
||||
<span>Итого: <b className="text-foreground">{formatCurrency(totalCredit - totalDebit, snap.settings[0]?.baseCurrency ?? 'RUB')}</b></span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<TableCard
|
||||
<DataGridCard
|
||||
title="Здоровье инвентаря"
|
||||
description="Подсказки: нет проекта, нет ставки, просрочка, устаревший синк, расхождения баланса"
|
||||
actions={
|
||||
@@ -103,88 +95,88 @@ function DashboardPage() {
|
||||
<Badge variant="secondary">всё ок</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{issues.length === 0 ? (
|
||||
<div className="p-4">
|
||||
<EmptyState
|
||||
icon={<TrendingUpIcon className="size-8" />}
|
||||
title="Проблем не найдено"
|
||||
description="Все активные VPS имеют проект, ставку и актуальный синк"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Проблема</TableHead>
|
||||
<TableHead className="w-24 text-right">Кол-во</TableHead>
|
||||
<TableHead className="w-32">Действие</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{issues.map((issue) => (
|
||||
<TableRow key={issue.key}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="size-4 text-destructive" />
|
||||
<div className="flex flex-col">
|
||||
<span>{issue.title}</span>
|
||||
{issue.hint ? (
|
||||
<span className="text-xs text-muted-foreground">{issue.hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{issue.count}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate({ to: issue.to })}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard>
|
||||
columns={[
|
||||
{
|
||||
id: 'title',
|
||||
header: 'Проблема',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangleIcon className="size-4 text-destructive" />
|
||||
<div className="flex flex-col">
|
||||
<span>{row.original.title}</span>
|
||||
{row.original.hint ? (
|
||||
<span className="text-xs text-muted-foreground">{row.original.hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'count',
|
||||
header: 'Кол-во',
|
||||
cell: ({ row }) => <span className="text-right tabular-nums">{row.original.count}</span>,
|
||||
meta: { align: 'right' },
|
||||
},
|
||||
{
|
||||
id: 'action',
|
||||
header: 'Действие',
|
||||
cell: ({ row }) => (
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: row.original.to })}>
|
||||
Открыть
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<{ key: string; title: string; count: number; to: string; hint?: string }>[]}
|
||||
data={issues}
|
||||
rowId={(i) => i.key}
|
||||
emptyTitle="Проблем не найдено"
|
||||
emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк"
|
||||
/>
|
||||
|
||||
<TableCard title="Последние VPS" description="Активные серверы">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>IP / DNS</TableHead>
|
||||
<TableHead>Проект</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="text-right">Ставка/мес</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeVps.slice(0, 8).map((v) => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell className="font-medium">{v.ip || v.dns}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{v.project || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={v.status === 'active' ? 'default' : 'secondary'}>
|
||||
{vpsStatusLabel(v.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatInBaseCurrency(
|
||||
v.tariffType === 'daily' ? Number(v.dailyRate || 0) * 30 : Number(v.monthlyRate || 0),
|
||||
v.currency,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableCard>
|
||||
<DataGridCard
|
||||
title="Последние VPS"
|
||||
description="Активные серверы"
|
||||
columns={[
|
||||
{
|
||||
id: 'ip',
|
||||
header: 'IP / DNS',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.ip || row.original.dns}</span>,
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
header: 'Проект',
|
||||
cell: ({ row }) => <span className="text-muted-foreground">{row.original.project || '—'}</span>,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.status === 'active' ? 'default' : 'secondary'}>
|
||||
{vpsStatusLabel(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'rate',
|
||||
header: 'Ставка/мес',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-right tabular-nums">
|
||||
{formatInBaseCurrency(
|
||||
row.original.tariffType === 'daily'
|
||||
? Number(row.original.dailyRate || 0) * 30
|
||||
: Number(row.original.monthlyRate || 0),
|
||||
row.original.currency,
|
||||
snap.settings,
|
||||
ratesData,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<typeof activeVps[number]>[]}
|
||||
data={activeVps.slice(0, 8)}
|
||||
rowId={(v) => v.id}
|
||||
pagination={false}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -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<Record<string, number>>((acc, p) => {
|
||||
const cur = p.currency ?? 'RUB'
|
||||
acc[cur] = (acc[cur] ?? 0) + Number(p.amount || 0)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -141,7 +148,23 @@ function PaymentsPage() {
|
||||
emptyTitle="Платежей нет"
|
||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить платёж</Button>}
|
||||
>
|
||||
{() => <DataTableCard columns={columns} data={sorted} rowKey={(p) => p.id} />}
|
||||
{() => (
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={sorted}
|
||||
rowId={(p) => p.id}
|
||||
pinLastColumn
|
||||
virtualization={sorted.length > 200}
|
||||
height={560}
|
||||
footerContent={
|
||||
<div className="flex flex-wrap justify-end gap-6 px-3 py-2 text-sm tabular-nums">
|
||||
{Object.entries(totalByCurrency).map(([cur, sum]) => (
|
||||
<span key={cur}>Итого {cur}: <b className="text-foreground">{formatCurrency(sum, cur)}</b></span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
|
||||
@@ -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'
|
||||
@@ -138,7 +139,7 @@ function ProvidersPage() {
|
||||
emptyTitle="Хостеры не найдены"
|
||||
emptyAction={<Button onClick={openCreate}><PlusIcon data-icon="inline-start" />Добавить хостера</Button>}
|
||||
>
|
||||
{(snap) => <DataTableCard columns={columns} data={snap.providers} rowKey={(p) => p.id} />}
|
||||
{(snap) => <DataGridCard columns={columnDefFromDataTable(columns)} data={snap.providers} rowId={(p) => p.id} pinLastColumn />}
|
||||
</QueryState>
|
||||
|
||||
<FormSheet
|
||||
|
||||
@@ -5,7 +5,8 @@ import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
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 { EmptyState } from '@/components/empty-state'
|
||||
@@ -71,7 +72,7 @@ function TariffsPage() {
|
||||
emptyDescription="Выполните синхронизацию аккаунта BILLmanager, чтобы загрузить тарифы"
|
||||
emptyAction={<EmptyState icon={<ServerCogIcon className="size-8" />} title="Нет тарифов" />}
|
||||
>
|
||||
{(snap) => <DataTableCard columns={columns} data={snap.activeTariffs} rowKey={(t) => t.id} />}
|
||||
{(snap) => <DataGridCard columns={columnDefFromDataTable(columns)} data={snap.activeTariffs} rowId={(t) => t.id} />}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
@@ -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) => (
|
||||
<DataTableCard
|
||||
<DataGridCard
|
||||
key={section.key}
|
||||
title={section.label ?? undefined}
|
||||
columns={columns}
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={section.items}
|
||||
rowKey={(v) => v.id}
|
||||
rowId={(v) => v.id}
|
||||
emptyTitle="VPS не найдены"
|
||||
pinLastColumn
|
||||
virtualization={section.items.length > 200}
|
||||
height={560}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
@@ -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 {
|
||||
|
||||
Generated
+114
@@ -93,6 +93,18 @@ importers:
|
||||
'@cfdm/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1([email protected]([email protected]))([email protected])
|
||||
'@dnd-kit/modifiers':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2([email protected])
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.10.0
|
||||
version: 3.10.0([email protected]([email protected]))
|
||||
@@ -108,6 +120,12 @@ importers:
|
||||
'@tanstack/react-router-devtools':
|
||||
specifier: ^1.130.2
|
||||
version: 1.167.0(@tanstack/[email protected]([email protected]([email protected]))([email protected]))(@tanstack/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3([email protected]([email protected]))([email protected])
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.4
|
||||
version: 3.14.4([email protected]([email protected]))([email protected])
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -369,6 +387,34 @@ packages:
|
||||
'@date-fns/[email protected]':
|
||||
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
|
||||
|
||||
'@dnd-kit/[email protected]':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/[email protected]':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/[email protected]':
|
||||
resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/[email protected]':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/[email protected]':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@drizzle-team/[email protected]':
|
||||
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/[email protected]':
|
||||
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
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/[email protected]':
|
||||
resolution: {integrity: sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -1462,6 +1521,13 @@ packages:
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==}
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
|
||||
engines: {node: '>=20.19'}
|
||||
@@ -3518,6 +3584,38 @@ snapshots:
|
||||
|
||||
'@date-fns/[email protected]': {}
|
||||
|
||||
'@dnd-kit/[email protected]([email protected])':
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/[email protected]([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1([email protected])
|
||||
'@dnd-kit/utilities': 3.2.2([email protected])
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7([email protected])
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
|
||||
'@dnd-kit/utilities': 3.2.2([email protected])
|
||||
react: 19.2.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/[email protected](@dnd-kit/[email protected]([email protected]([email protected]))([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1([email protected]([email protected]))([email protected])
|
||||
'@dnd-kit/utilities': 3.2.2([email protected])
|
||||
react: 19.2.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/[email protected]([email protected])':
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
tslib: 2.8.1
|
||||
|
||||
'@drizzle-team/[email protected]': {}
|
||||
|
||||
'@esbuild-kit/[email protected]':
|
||||
@@ -4257,6 +4355,18 @@ snapshots:
|
||||
react-dom: 19.2.7([email protected])
|
||||
use-sync-external-store: 1.6.0([email protected])
|
||||
|
||||
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7([email protected])
|
||||
|
||||
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.2
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7([email protected])
|
||||
|
||||
'@tanstack/[email protected]':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.162.0
|
||||
@@ -4324,6 +4434,10 @@ snapshots:
|
||||
|
||||
'@tanstack/[email protected]': {}
|
||||
|
||||
'@tanstack/[email protected]': {}
|
||||
|
||||
'@tanstack/[email protected]': {}
|
||||
|
||||
'@tanstack/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
|
||||
Reference in New Issue
Block a user