feat(ui): перевести DataGrid на ReUI v9 и матрицу блокировок
Docker / build (push) Failing after 26s

Обновить registry data-grid на TanStack Table v9 и показать статус DPI компактной матрицей по VPS и сервисам.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-22 16:06:49 +07:00
co-authored by Cursor
parent ca7f37f980
commit 5847188dc7
27 changed files with 3298 additions and 1088 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-router": "^1.130.2", "@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.130.2", "@tanstack/react-router-devtools": "^1.130.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^9.1.2",
"@tanstack/react-virtual": "^3.14.4", "@tanstack/react-virtual": "^3.14.4",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters' import { filterCensorcheckRuns, groupRunsByService, collectServiceColumns, collectProbeColumns, shortHostLabel } from './blocking-filters'
import type { CensorcheckRunDto } from './types' import type { CensorcheckRunDto } from './types'
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({ const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
@@ -95,5 +95,49 @@ describe('groupRunsByService', () => {
const groups = groupRunsByService([run()]) const groups = groupRunsByService([run()])
expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com']) expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com'])
expect(groups[1]?.probes[0]?.status).toBe('blocked') expect(groups[1]?.probes[0]?.status).toBe('blocked')
expect(groups[1]?.probes[0]?.httpStatus).toBe(-1)
}) })
}) })
describe('collectServiceColumns', () => {
it('ставит канонические сервисы первыми и custom в конец', () => {
const cols = collectServiceColumns([
run({
results: [
...(run().results ?? []),
{
id: 'r3',
runId: 'ccrun-1',
serviceKey: 'custom.example',
serviceLabel: 'custom.example',
category: 'custom',
status: 'available',
httpStatus: 200,
detail: null,
},
],
}),
])
expect(cols[0]?.key).toBe('youtube.com')
expect(cols.map((c) => c.key)).toContain('netflix.com')
expect(cols.at(-1)?.key).toBe('custom.example')
})
})
describe('collectProbeColumns', () => {
it('берёт dns как короткий header', () => {
expect(collectProbeColumns([run()])[0]).toMatchObject({
key: 'ccrun-1',
label: 'edge.example',
title: 'edge.example.com',
})
})
})
describe('shortHostLabel', () => {
it('отрезает типичный TLD', () => {
expect(shortHostLabel('youtube.com')).toBe('youtube')
expect(shortHostLabel('api.telegram.org')).toBe('api.telegram')
})
})
@@ -1,6 +1,15 @@
import { getActiveFilters } from '@/components/reui-kit' import { getActiveFilters } from '@/components/reui-kit'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import { runSearchText, type CensorcheckRunDto } from './types' import {
CENSORCHECK_DPI_HOSTS,
CENSORCHECK_GEOBLOCK_HOSTS,
inferCensorcheckCategory,
} from '@cfdm/shared/contracts/censorcheck'
import {
runSearchText,
type CensorcheckResultDto,
type CensorcheckRunDto,
} from './types'
export function filterCensorcheckRuns( export function filterCensorcheckRuns(
runs: CensorcheckRunDto[], runs: CensorcheckRunDto[],
@@ -68,6 +77,7 @@ export type BlockingServiceRow = {
dns: string dns: string
country: string country: string
status: string status: string
httpStatus: number | null
createdAt: string createdAt: string
vpsId: string | null vpsId: string | null
}> }>
@@ -85,6 +95,7 @@ export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRo
dns: run.vps?.dns ?? '', dns: run.vps?.dns ?? '',
country: run.vps?.country ?? '', country: run.vps?.country ?? '',
status: result.status, status: result.status,
httpStatus: result.httpStatus,
createdAt: run.createdAt, createdAt: run.createdAt,
vpsId: run.matchedVpsId, vpsId: run.matchedVpsId,
} }
@@ -103,3 +114,68 @@ export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRo
} }
return [...map.values()].sort((a, b) => a.serviceKey.localeCompare(b.serviceKey)) return [...map.values()].sort((a, b) => a.serviceKey.localeCompare(b.serviceKey))
} }
export type MatrixColumn = {
key: string
label: string
title: string
}
export function shortHostLabel(value: string): string {
const host = value.trim().split('/')[0] ?? value
return host.replace(/\.(com|org|net|io|ag|is)$/i, '')
}
const CANONICAL_SERVICES = [...CENSORCHECK_DPI_HOSTS, ...CENSORCHECK_GEOBLOCK_HOSTS]
export function collectServiceColumns(runs: CensorcheckRunDto[]): MatrixColumn[] {
const canonicalSet = new Set<string>(CANONICAL_SERVICES)
const extras = new Set<string>()
for (const run of runs) {
for (const result of run.results ?? []) {
if (!canonicalSet.has(result.serviceKey)) extras.add(result.serviceKey)
}
}
const keys = [
...CANONICAL_SERVICES,
...[...extras].sort((a, b) => a.localeCompare(b)),
]
return keys.map((key) => ({
key,
label: shortHostLabel(key),
title: key,
}))
}
export function collectProbeColumns(runs: CensorcheckRunDto[]): MatrixColumn[] {
return runs.map((run) => {
const title = run.vps?.dns || run.probePublicIp
return {
key: run.id,
label: shortHostLabel(title),
title,
}
})
}
export function resultByService(
run: CensorcheckRunDto,
serviceKey: string,
): CensorcheckResultDto | undefined {
return (run.results ?? []).find((row) => row.serviceKey === serviceKey)
}
export function serviceMatrixRows(runs: CensorcheckRunDto[]): BlockingServiceRow[] {
const grouped = new Map(groupRunsByService(runs).map((row) => [row.serviceKey, row]))
return collectServiceColumns(runs).map((col) => {
const existing = grouped.get(col.key)
if (existing) return existing
return {
id: col.key,
serviceKey: col.key,
serviceLabel: col.key,
category: inferCensorcheckCategory(col.key),
probes: [],
}
})
}
@@ -1,74 +1,31 @@
import type { ReactNode } from 'react' import { useMemo, type ReactNode } from 'react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react' import { ServerIcon, ShieldAlertIcon } from 'lucide-react'
import type { DataGridColumn } from '@/components/data-grid-types' import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells' import { dataGridCellStack } from '@/components/data-grid-cells'
import { CountryFlag } from '@/components/country-flag' import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import { columnDefFromDataGrid, ExpandableResourceGrid } from '@/components/reui-kit'
import { Badge } from '@/components/reui/badge'
import { import {
CENSORCHECK_STATUS_LABELS, collectProbeColumns,
formatCheckedAt, collectServiceColumns,
formatVpsResources, resultByService,
type CensorcheckRunDto, type BlockingServiceRow,
} from './types' } from './blocking-filters'
import type { BlockingServiceRow } from './blocking-filters' import { StatusMatrixCell } from './status-matrix-cell'
import type { CensorcheckRunDto } from './types'
function SummaryBadges({ run }: { run: CensorcheckRunDto }) { const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center'
const { summary } = run
return (
<div className="flex flex-wrap items-center gap-1">
{summary.available > 0 ? (
<Badge variant="success" size="sm">{summary.available} ок</Badge>
) : null}
{summary.blocked > 0 ? (
<Badge variant="destructive" size="sm">{summary.blocked} блок</Badge>
) : null}
{summary.denied > 0 ? (
<Badge variant="destructive" size="sm">{summary.denied} отказ</Badge>
) : null}
{summary.timeout > 0 ? (
<Badge variant="warning" size="sm">{summary.timeout} timeout</Badge>
) : null}
{summary.error > 0 ? (
<Badge variant="outline" size="sm">{summary.error} err</Badge>
) : null}
</div>
)
}
function NestedList({ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
rows, return {
}: {
rows: Array<{ key: string; primary: string; secondary?: string; status: string }>
}) {
return (
<div className="bg-muted/30 flex flex-col gap-1 px-4 py-3">
{rows.map((row) => (
<div key={row.key} className="flex items-center justify-between gap-3 text-sm">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{row.primary}</span>
{row.secondary ? (
<span className="text-muted-foreground truncate text-xs">{row.secondary}</span>
) : null}
</div>
<StatusBadge
status={row.status}
label={CENSORCHECK_STATUS_LABELS[row.status] ?? row.status}
/>
</div>
))}
</div>
)
}
const vpsColumns: DataGridColumn<CensorcheckRunDto>[] = [
{
key: 'vps', key: 'vps',
header: 'VPS / IP', header: 'VPS / IP',
headerTitle: 'VPS / IP',
icon: ServerIcon, icon: ServerIcon,
enableHiding: false,
enablePinning: true,
size: 220,
minSize: 180,
sortValue: (row) => row.vps?.dns || row.probePublicIp, sortValue: (row) => row.vps?.dns || row.probePublicIp,
cell: (row) => { cell: (row) => {
const title = row.vps?.dns || row.probePublicIp const title = row.vps?.dns || row.probePublicIp
@@ -87,68 +44,8 @@ const vpsColumns: DataGridColumn<CensorcheckRunDto>[] = [
) )
return dataGridCellStack(link, ip) return dataGridCellStack(link, ip)
}, },
}, }
{ }
key: 'dns',
header: 'DNS',
icon: GlobeIcon,
sortValue: (row) => row.vps?.dns ?? '',
cell: (row) => row.vps?.dns || '—',
},
{
key: 'hoster',
header: 'Хостер',
sortValue: (row) => row.vps?.providerName ?? '',
cell: (row) => row.vps?.providerName || '—',
},
{
key: 'country',
header: 'Страна',
icon: MapPinIcon,
sortValue: (row) => row.vps?.country ?? '',
cell: (row) =>
row.vps?.country
? dataGridCellWithFlag(<CountryFlag country={row.vps.country} />, row.vps.country)
: '—',
},
{
key: 'resources',
header: 'Ресурсы',
sortValue: (row) => row.vps?.vcpu ?? 0,
cell: (row) =>
row.vps
? formatVpsResources(row.vps.vcpu, row.vps.ramGb, row.vps.diskGb)
: '—',
},
{
key: 'summary',
header: 'Сводка',
cell: (row) => <SummaryBadges run={row} />,
},
{
key: 'checked',
header: 'Проверено',
sortValue: (row) => row.createdAt,
cell: (row) => formatCheckedAt(row.createdAt),
},
]
const serviceColumns: DataGridColumn<BlockingServiceRow>[] = [
{
key: 'service',
header: 'Сервис',
icon: ShieldAlertIcon,
sortValue: (row) => row.serviceKey,
cell: (row) => dataGridCellStack(row.serviceLabel, row.category),
},
{
key: 'probes',
header: 'Пробы',
sortValue: (row) => row.probes.length,
sortingFn: 'basic',
cell: (row) => row.probes.length,
},
]
export function BlockingVpsGrid({ export function BlockingVpsGrid({
runs, runs,
@@ -159,59 +56,133 @@ export function BlockingVpsGrid({
onRowClick: (run: CensorcheckRunDto) => void onRowClick: (run: CensorcheckRunDto) => void
emptyAction?: ReactNode emptyAction?: ReactNode
}) { }) {
const serviceCols = useMemo(() => collectServiceColumns(runs), [runs])
const columns = useMemo((): DataGridColumn<CensorcheckRunDto>[] => {
return [
vpsIdentityColumn(),
...serviceCols.map(
(svc): DataGridColumn<CensorcheckRunDto> => ({
key: `svc:${svc.key}`,
header: (
<span className="block max-w-16 truncate" title={svc.title}>
{svc.label}
</span>
),
headerTitle: svc.title,
className: MATRIX_CELL,
headerClassName: MATRIX_CELL,
size: 72,
minSize: 64,
sortable: true,
sortValue: (row) => resultByService(row, svc.key)?.status ?? '',
cell: (row) => {
const item = resultByService(row, svc.key)
return (
<StatusMatrixCell
status={item?.status}
serviceLabel={svc.title}
vpsLabel={row.vps?.dns || row.probePublicIp}
httpStatus={item?.httpStatus}
checkedAt={row.createdAt}
/>
)
},
}),
),
]
}, [serviceCols])
return ( return (
<ExpandableResourceGrid <FrameDataGrid
columns={columnDefFromDataGrid(vpsColumns)} columns={columnDefFromDataGrid(columns)}
data={runs} data={runs}
rowId={(row) => row.id} rowId={(row) => row.id}
dense dense
pagination={runs.length > 10} pagination={runs.length > 10}
pinLeftColumnIds={['vps']}
horizontalScroll
emptyTitle="Нет проверок" emptyTitle="Нет проверок"
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок." emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
emptyAction={emptyAction} emptyAction={emptyAction}
onRowClick={onRowClick} onRowClick={onRowClick}
getRowCanExpand={(row) => (row.results?.length ?? 0) > 0}
expandedContent={(row) => (
<NestedList
rows={(row.results ?? []).map((item) => ({
key: item.id,
primary: item.serviceLabel,
secondary: item.category,
status: item.status,
}))}
/>
)}
/> />
) )
} }
export function BlockingServiceGrid({ export function BlockingServiceGrid({
groups, groups,
runs,
onProbeClick,
emptyAction, emptyAction,
}: { }: {
groups: BlockingServiceRow[] groups: BlockingServiceRow[]
runs: CensorcheckRunDto[]
onProbeClick: (run: CensorcheckRunDto) => void
emptyAction?: ReactNode emptyAction?: ReactNode
}) { }) {
const probeCols = useMemo(() => collectProbeColumns(runs), [runs])
const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs])
const columns = useMemo((): DataGridColumn<BlockingServiceRow>[] => {
return [
{
key: 'service',
header: 'Сервис',
headerTitle: 'Сервис',
icon: ShieldAlertIcon,
enableHiding: false,
enablePinning: true,
size: 180,
minSize: 140,
sortValue: (row) => row.serviceKey,
cell: (row) => dataGridCellStack(row.serviceLabel, row.category),
},
...probeCols.map(
(probe): DataGridColumn<BlockingServiceRow> => ({
key: `probe:${probe.key}`,
header: (
<span className="block max-w-16 truncate" title={probe.title}>
{probe.label}
</span>
),
headerTitle: probe.title,
className: MATRIX_CELL,
headerClassName: MATRIX_CELL,
size: 72,
minSize: 64,
sortable: true,
sortValue: (row) =>
row.probes.find((item) => item.runId === probe.key)?.status ?? '',
cell: (row) => {
const item = row.probes.find((probeRow) => probeRow.runId === probe.key)
const run = runById.get(probe.key)
return (
<StatusMatrixCell
status={item?.status}
serviceLabel={row.serviceKey}
vpsLabel={probe.title}
httpStatus={item?.httpStatus}
checkedAt={item?.createdAt}
onSelect={run ? () => onProbeClick(run) : undefined}
/>
)
},
}),
),
]
}, [onProbeClick, probeCols, runById])
return ( return (
<ExpandableResourceGrid <FrameDataGrid
columns={columnDefFromDataGrid(serviceColumns)} columns={columnDefFromDataGrid(columns)}
data={groups} data={groups}
rowId={(row) => row.id} rowId={(row) => row.id}
dense dense
pagination={groups.length > 10} pagination={groups.length > 10}
pinLeftColumnIds={['service']}
horizontalScroll
emptyTitle="Нет сервисов" emptyTitle="Нет сервисов"
emptyAction={emptyAction} emptyAction={emptyAction}
getRowCanExpand={(row) => row.probes.length > 0}
expandedContent={(row) => (
<NestedList
rows={row.probes.map((probe) => ({
key: `${probe.runId}-${probe.probePublicIp}`,
primary: probe.dns || probe.probePublicIp,
secondary: `${probe.probePublicIp} · ${formatCheckedAt(probe.createdAt)}`,
status: probe.status,
}))}
/>
)}
/> />
) )
} }
@@ -27,7 +27,7 @@ import { StatusBadge } from '@/components/status-badge'
import type { DataGridColumn } from '@/components/data-grid-types' import type { DataGridColumn } from '@/components/data-grid-types'
import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid' import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
import { CheckRunSheet } from './check-run-sheet' import { CheckRunSheet } from './check-run-sheet'
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters' import { filterCensorcheckRuns, serviceMatrixRows } from './blocking-filters'
import { import {
CENSORCHECK_STATUS_LABELS, CENSORCHECK_STATUS_LABELS,
LAUNCHER_CMD, LAUNCHER_CMD,
@@ -119,7 +119,7 @@ export function BlockingPage() {
const runs = currentQuery.data?.items ?? [] const runs = currentQuery.data?.items ?? []
const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters]) const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters])
const serviceGroups = useMemo(() => groupRunsByService(filtered), [filtered]) const serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered])
const matched = filtered.filter((row) => row.matchedVpsId).length const matched = filtered.filter((row) => row.matchedVpsId).length
const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0) const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0)
@@ -230,7 +230,12 @@ export function BlockingPage() {
emptyAction={copyLauncher} emptyAction={copyLauncher}
/> />
) : ( ) : (
<BlockingServiceGrid groups={serviceGroups} emptyAction={copyLauncher} /> <BlockingServiceGrid
groups={serviceGroups}
runs={rows}
onProbeClick={setSelected}
emptyAction={copyLauncher}
/>
) )
} }
</QueryState> </QueryState>
@@ -0,0 +1,75 @@
import { Badge } from '@/components/reui/badge'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { StatusBadge } from '@/components/status-badge'
import { CENSORCHECK_STATUS_LABELS, formatCheckedAt } from './types'
/** Compact timesheet-style cell — preview: https://reui.io/preview/base/data-grid-base-4 */
const MATRIX_SHORT: Record<string, string> = {
available: 'ОК',
blocked: 'Блок',
denied: 'Отказ',
timeout: 'TO',
redirected: '3xx',
error: 'Err',
}
export function StatusMatrixCell({
status,
serviceLabel,
vpsLabel,
httpStatus,
checkedAt,
onSelect,
}: {
status?: string | null
serviceLabel: string
vpsLabel: string
httpStatus?: number | null
checkedAt?: string
onSelect?: () => void
}) {
const short = status ? (MATRIX_SHORT[status] ?? status) : '—'
const full = status ? (CENSORCHECK_STATUS_LABELS[status] ?? status) : 'Нет результата'
const tip = [
serviceLabel,
vpsLabel,
full,
httpStatus != null ? `HTTP ${httpStatus}` : null,
checkedAt ? formatCheckedAt(checkedAt) : null,
]
.filter(Boolean)
.join(' · ')
const badge = status ? (
<StatusBadge status={status} label={short} size="sm" />
) : (
<Badge variant="outline" size="sm" className="text-muted-foreground">
</Badge>
)
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="inline-flex"
onClick={(event) => {
if (!onSelect) return
event.stopPropagation()
onSelect()
}}
/>
}
>
{badge}
</TooltipTrigger>
<TooltipContent>{tip}</TooltipContent>
</Tooltip>
)
}
+5 -1
View File
@@ -8,12 +8,16 @@ export interface DataGridColumn<T> {
icon?: LucideIcon icon?: LucideIcon
sortable?: boolean sortable?: boolean
sortValue?: (row: T) => string | number sortValue?: (row: T) => string | number
/** TanStack sortingFn; для числовых sortValue — `'basic'`. */ /** TanStack v9 `sortFn`; для числовых sortValue — `'basic'`. */
sortingFn?: 'auto' | 'alphanumeric' | 'basic' | 'text' | 'datetime' sortingFn?: 'auto' | 'alphanumeric' | 'basic' | 'text' | 'datetime'
headerTitle?: string headerTitle?: string
className?: string className?: string
headerClassName?: string headerClassName?: string
enableHiding?: boolean enableHiding?: boolean
size?: number
minSize?: number
maxSize?: number
enablePinning?: boolean
} }
/** @deprecated Используйте DataGridColumn */ /** @deprecated Используйте DataGridColumn */
@@ -1,28 +1,32 @@
import { useState, useEffect, type ReactNode } from 'react' import { useState, useEffect, type ReactNode } from 'react'
import { import {
useReactTable, useTable,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
getExpandedRowModel,
flexRender, flexRender,
type ColumnDef, type ColumnDef,
type SortingState, type SortingState,
type RowSelectionState, type RowSelectionState,
type VisibilityState, type ColumnVisibilityState,
type ExpandedState, type ExpandedState,
type OnChangeFn, type OnChangeFn,
type PaginationState,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { ChevronDownIcon, ChevronRightIcon, Columns3Icon } from 'lucide-react' import { Columns3Icon } from 'lucide-react'
import { Checkbox } from '@cfdm/ui/components/checkbox'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { import {
DataGrid, DataGrid,
DataGridContainer, DataGridContainer,
dataGridFeatures,
type DataGridFeatures,
type DataGridTableInstance,
} from '@/components/reui/data-grid/data-grid' } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import {
DataGridTable,
DataGridTableRowExpand,
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual' import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
@@ -39,14 +43,16 @@ import {
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
export type DataGridColumnDef<TData extends object> = ColumnDef<
DataGridFeatures,
TData
>
const PAGINATION_LABELS = { const PAGINATION_LABELS = {
rowsPerPageLabel: 'Строк на странице', rowsPerPageLabel: 'Строк на странице',
info: '{from}{to} из {count}', info: '{from}{to} из {count}',
previousPageLabel: 'Предыдущая страница', previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница', nextPageLabel: 'Следующая страница',
pageLabel: 'Страница {page}',
previousPagesLabel: 'Предыдущие страницы',
nextPagesLabel: 'Следующие страницы',
} as const } as const
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string { function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
@@ -55,11 +61,11 @@ function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
return '' return ''
} }
function loadStoredColumnVisibility(key: string): VisibilityState | undefined { function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
try { try {
const raw = localStorage.getItem(key) const raw = localStorage.getItem(key)
if (!raw) return undefined if (!raw) return undefined
return JSON.parse(raw) as VisibilityState return JSON.parse(raw) as ColumnVisibilityState
} catch { } catch {
return undefined return undefined
} }
@@ -87,7 +93,7 @@ export interface FrameDataGridProps<TData extends object> {
title?: ReactNode title?: ReactNode
description?: ReactNode description?: ReactNode
actions?: ReactNode actions?: ReactNode
columns: ColumnDef<TData, unknown>[] columns: DataGridColumnDef<TData>[]
data: TData[] data: TData[]
/** Ключ строки — функция, возвращающая уникальный id. */ /** Ключ строки — функция, возвращающая уникальный id. */
rowId?: (row: TData, index: number) => string rowId?: (row: TData, index: number) => string
@@ -118,18 +124,22 @@ export interface FrameDataGridProps<TData extends object> {
/** Показать picker видимости колонок. */ /** Показать picker видимости колонок. */
enableColumnVisibility?: boolean enableColumnVisibility?: boolean
/** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */ /** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */
columnVisibility?: VisibilityState columnVisibility?: ColumnVisibilityState
onColumnVisibilityChange?: OnChangeFn<VisibilityState> onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>
/** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */ /** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */
columnVisibilityTrigger?: boolean columnVisibilityTrigger?: boolean
/** Ключ localStorage для сохранения видимости колонок. */ /** Ключ localStorage для сохранения видимости колонок. */
columnVisibilityStorageKey?: string columnVisibilityStorageKey?: string
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */ /** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
initialColumnVisibility?: VisibilityState initialColumnVisibility?: ColumnVisibilityState
className?: string className?: string
/** Expandable rows — c-data-grid-8 / https://reui.io/preview/base/components/c-data-grid-8 */ /** Expandable rows — c-data-grid-8 / https://reui.io/preview/base/components/c-data-grid-8 */
expandedContent?: (row: TData) => ReactNode expandedContent?: (row: TData) => ReactNode
getRowCanExpand?: (row: TData) => boolean getRowCanExpand?: (row: TData) => boolean
/** Закрепить колонки слева (ids). Timesheet DNA: https://reui.io/preview/base/data-grid-base-4 */
pinLeftColumnIds?: string[]
/** Горизонтальный скролл широкой матрицы. */
horizontalScroll?: boolean
} }
function DataGridSectionHeader({ function DataGridSectionHeader({
@@ -177,8 +187,10 @@ function FrameDataGridBody<TData extends object>({
footerContent, footerContent,
showPagination, showPagination,
enableColumnVisibility, enableColumnVisibility,
columnsPinnable,
horizontalScroll,
}: { }: {
table: ReturnType<typeof useReactTable<TData>> table: DataGridTableInstance<TData>
data: TData[] data: TData[]
emptyTitle: string emptyTitle: string
onRowClick?: (row: TData) => void onRowClick?: (row: TData) => void
@@ -188,7 +200,15 @@ function FrameDataGridBody<TData extends object>({
footerContent?: ReactNode footerContent?: ReactNode
showPagination: boolean showPagination: boolean
enableColumnVisibility: boolean enableColumnVisibility: boolean
columnsPinnable: boolean
horizontalScroll: boolean
}) { }) {
const tableNode = virtualization ? (
<DataGridTableVirtual height={height} footerContent={footerContent} />
) : (
<DataGridTable footerContent={footerContent} />
)
return ( return (
<DataGrid <DataGrid
table={table} table={table}
@@ -205,7 +225,7 @@ function FrameDataGridBody<TData extends object>({
width: 'auto', width: 'auto',
columnsVisibility: enableColumnVisibility, columnsVisibility: enableColumnVisibility,
columnsResizable: false, columnsResizable: false,
columnsPinnable: false, columnsPinnable,
columnsMovable: false, columnsMovable: false,
rowsDraggable: false, rowsDraggable: false,
rowsPinnable: false, rowsPinnable: false,
@@ -215,12 +235,15 @@ function FrameDataGridBody<TData extends object>({
}} }}
> >
<DataGridContainer border={false}> <DataGridContainer border={false}>
{virtualization ? ( {virtualization || horizontalScroll ? (
<DataGridScrollArea orientation="vertical" style={{ height }}> <DataGridScrollArea
<DataGridTableVirtual height={height} footerContent={footerContent} /> orientation={virtualization && horizontalScroll ? 'both' : virtualization ? 'vertical' : 'both'}
style={virtualization ? { height } : { maxHeight: 'min(70vh, 40rem)' }}
>
{tableNode}
</DataGridScrollArea> </DataGridScrollArea>
) : ( ) : (
<DataGridTable footerContent={footerContent} /> tableNode
)} )}
</DataGridContainer> </DataGridContainer>
{showPagination ? <DataGridPaginationBar /> : null} {showPagination ? <DataGridPaginationBar /> : null}
@@ -258,11 +281,18 @@ export function FrameDataGrid<TData extends object>({
className, className,
expandedContent, expandedContent,
getRowCanExpand, getRowCanExpand,
pinLeftColumnIds,
horizontalScroll = false,
}: FrameDataGridProps<TData>) { }: FrameDataGridProps<TData>) {
const showPagination = pagination ?? true
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? []) const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [expanded, setExpanded] = useState<ExpandedState>({}) const [expanded, setExpanded] = useState<ExpandedState>({})
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => { const [paginationState, setPaginationState] = useState<PaginationState>({
pageIndex: 0,
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
})
const [internalColumnVisibility, setInternalColumnVisibility] = useState<ColumnVisibilityState>(() => {
const stored = columnVisibilityStorageKey const stored = columnVisibilityStorageKey
? loadStoredColumnVisibility(columnVisibilityStorageKey) ? loadStoredColumnVisibility(columnVisibilityStorageKey)
: undefined : undefined
@@ -271,89 +301,73 @@ export function FrameDataGrid<TData extends object>({
const isColumnVisibilityControlled = columnVisibilityProp !== undefined const isColumnVisibilityControlled = columnVisibilityProp !== undefined
const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility
const setColumnVisibility: OnChangeFn<VisibilityState> = isColumnVisibilityControlled const setColumnVisibility: OnChangeFn<ColumnVisibilityState> = isColumnVisibilityControlled
? (onColumnVisibilityChange ?? (() => undefined)) ? (onColumnVisibilityChange ?? (() => undefined))
: setInternalColumnVisibility : setInternalColumnVisibility
useEffect(() => {
setPaginationState((current) => ({
pageIndex: showPagination ? current.pageIndex : 0,
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
}))
}, [pageSize, showPagination])
useEffect(() => { useEffect(() => {
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility)) localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled]) }, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
const selectColumn: ColumnDef<TData, unknown> = { const selectColumn: DataGridColumnDef<TData> = {
id: 'select', id: 'select',
header: ({ table }) => ( header: () => <DataGridTableRowSelectAll />,
<Checkbox cell: ({ row }) => <DataGridTableRowSelect row={row} />,
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Выбрать все"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Выбрать строку"
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40,
meta: { cellClassName: 'w-10' }, meta: { cellClassName: 'w-10' },
} }
const expandColumn: ColumnDef<TData, unknown> = { const expandColumn: DataGridColumnDef<TData> = {
id: 'expand', id: 'expand',
header: () => null, header: () => null,
cell: ({ row }) => cell: ({ row }) => <DataGridTableRowExpand row={row} />,
row.getCanExpand() ? (
<Button
type="button"
variant="ghost"
size="icon-sm"
className="size-6 text-muted-foreground"
aria-label={row.getIsExpanded() ? 'Свернуть' : 'Развернуть'}
onClick={(event) => {
event.stopPropagation()
row.toggleExpanded()
}}
>
{row.getIsExpanded() ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</Button>
) : null,
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40,
meta: { meta: {
cellClassName: 'w-10', cellClassName: 'w-10',
expandedContent, expandedContent,
}, },
} }
const tableColumns = [ const tableColumns: DataGridColumnDef<TData>[] = [
...(expandedContent ? [expandColumn] : []), ...(expandedContent ? [expandColumn] : []),
...(enableRowSelection ? [selectColumn] : []), ...(enableRowSelection ? [selectColumn] : []),
...columns, ...columns,
] ]
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : '' const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
const pinLeft = pinLeftColumnIds ?? []
const enablePinning = pinLastColumn || pinLeft.length > 0
const columnPinning = {
start: pinLeft,
end: pinLastColumn && lastColId ? [lastColId] : [],
}
const showPagination = pagination ?? true const table = useTable({
features: dataGridFeatures,
const table = useReactTable<TData>({
data, data,
columns: tableColumns, columns: tableColumns,
state: { state: {
sorting, sorting,
pagination: paginationState,
columnVisibility, columnVisibility,
expanded, expanded,
...(enablePinning ? { columnPinning } : {}),
...(enableRowSelection ? { rowSelection } : {}), ...(enableRowSelection ? { rowSelection } : {}),
}, },
onSortingChange: setSorting, onSortingChange: setSorting,
onPaginationChange: setPaginationState,
onExpandedChange: setExpanded, onExpandedChange: setExpanded,
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: enableRowSelection onRowSelectionChange: enableRowSelection
@@ -368,21 +382,11 @@ export function FrameDataGrid<TData extends object>({
}) })
} }
: undefined, : undefined,
getCoreRowModel: getCoreRowModel(), initialState: enablePinning ? { columnPinning } : undefined,
getSortedRowModel: getSortedRowModel(), getRowId: rowId ? (row, index) => rowId(row, index) : undefined,
getExpandedRowModel: expandedContent ? getExpandedRowModel() : undefined,
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
initialState: {
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}),
},
getRowId: rowId
? (row, index) => rowId(row, index)
: undefined,
getRowCanExpand: expandedContent getRowCanExpand: expandedContent
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true) ? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
: undefined, : undefined,
enableColumnPinning: pinLastColumn,
enableRowSelection, enableRowSelection,
enableHiding: enableColumnVisibility, enableHiding: enableColumnVisibility,
}) })
@@ -438,6 +442,8 @@ export function FrameDataGrid<TData extends object>({
footerContent={footerContent} footerContent={footerContent}
showPagination={showPagination} showPagination={showPagination}
enableColumnVisibility={enableColumnVisibility} enableColumnVisibility={enableColumnVisibility}
columnsPinnable={enablePinning}
horizontalScroll={horizontalScroll}
/> />
) )
@@ -452,9 +458,9 @@ export function FrameDataGrid<TData extends object>({
} }
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */ /** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
export function columnDefFromDataGrid<T>( export function columnDefFromDataGrid<T extends object>(
cols: DataGridColumn<T>[], cols: DataGridColumn<T>[],
): ColumnDef<T, unknown>[] { ): DataGridColumnDef<T>[] {
return cols.map((c) => { return cols.map((c) => {
const title = resolveHeaderTitle(c.header, c.headerTitle) const title = resolveHeaderTitle(c.header, c.headerTitle)
const Icon = c.icon const Icon = c.icon
@@ -467,7 +473,7 @@ export function columnDefFromDataGrid<T>(
accessorFn: c.sortValue accessorFn: c.sortValue
? (row: T) => c.sortValue!(row) ? (row: T) => c.sortValue!(row)
: (row: T) => (row as Record<string, unknown>)[c.key] as string | number, : (row: T) => (row as Record<string, unknown>)[c.key] as string | number,
sortingFn: c.sortingFn ?? 'auto', sortFn: c.sortingFn ?? 'auto',
} }
: {}), : {}),
header: Icon header: Icon
@@ -482,6 +488,10 @@ export function columnDefFromDataGrid<T>(
cell: ({ row }) => c.cell(row.original, row.index), cell: ({ row }) => c.cell(row.original, row.index),
enableSorting: sortable, enableSorting: sortable,
enableHiding: c.enableHiding ?? true, enableHiding: c.enableHiding ?? true,
enablePinning: c.enablePinning,
size: c.size,
minSize: c.minSize,
maxSize: c.maxSize,
meta: { meta: {
headerTitle: title || undefined, headerTitle: title || undefined,
cellClassName: c.className, cellClassName: c.className,
@@ -18,6 +18,7 @@ export {
loadStoredColumnVisibility, loadStoredColumnVisibility,
dataGridColumnVisibilityOptions, dataGridColumnVisibilityOptions,
type FrameDataGridProps, type FrameDataGridProps,
type DataGridColumnDef,
type DataGridColumnVisibilityOption, type DataGridColumnVisibilityOption,
} from './frame-data-grid' } from './frame-data-grid'
export { ExpandableResourceGrid } from './expandable-resource-grid' export { ExpandableResourceGrid } from './expandable-resource-grid'
@@ -1,10 +1,6 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { import {
getCoreRowModel, useTable,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type PaginationState, type PaginationState,
type RowSelectionState, type RowSelectionState,
type SortingState, type SortingState,
@@ -13,7 +9,7 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs' import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid' import { DataGrid, dataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination' import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area' import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table' import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
@@ -42,6 +38,7 @@ import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils' import { applyFiltersToData } from './filter-utils'
import { import {
FrameDataGrid, FrameDataGrid,
type DataGridColumnDef,
type FrameDataGridProps, type FrameDataGridProps,
} from './frame-data-grid' } from './frame-data-grid'
@@ -84,7 +81,7 @@ export interface ResourcePageProps<T extends object> extends SimpleGridPassthrou
onFiltersChange?: (filters: Filter[]) => void onFiltersChange?: (filters: Filter[]) => void
onClearFilters?: () => void onClearFilters?: () => void
getFilterFieldValue?: (item: T, field: string) => unknown getFilterFieldValue?: (item: T, field: string) => unknown
columns: ColumnDef<T, unknown>[] columns: DataGridColumnDef<T>[]
data: T[] data: T[]
getRowId: (row: T, index?: number) => string getRowId: (row: T, index?: number) => string
isLoading?: boolean isLoading?: boolean
@@ -323,7 +320,8 @@ function ResourcePageFiltered<T extends object>({
setRowSelection({}) setRowSelection({})
}, []) }, [])
const table = useReactTable({ const table = useTable({
features: dataGridFeatures,
data: filteredData, data: filteredData,
columns, columns,
getRowId: (row) => getRowId(row), getRowId: (row) => getRowId(row),
@@ -332,9 +330,6 @@ function ResourcePageFiltered<T extends object>({
onSortingChange: setSorting, onSortingChange: setSorting,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination, onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}) })
const handleTabChange = useCallback( const handleTabChange = useCallback(
@@ -399,7 +394,15 @@ function ResourcePageFiltered<T extends object>({
table={table} table={table}
recordCount={filteredData.length} recordCount={filteredData.length}
emptyMessage="Нет записей по выбранным фильтрам." emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{ dense: true }} tableLayout={{
dense: true,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
}}
> >
<Frame dense variant="default" spacing="sm" className="w-full"> <Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? ( {!hideHeader ? (
@@ -495,7 +498,7 @@ function ResourcePageFiltered<T extends object>({
<DataGridPagination <DataGridPagination
sizes={[5, 10, 20, 50]} sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице" rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}" info="{from}{to} из {count}"
previousPageLabel="Предыдущая" previousPageLabel="Предыдущая"
nextPageLabel="Следующая" nextPageLabel="Следующая"
/> />
@@ -1,6 +1,9 @@
"use client"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge" import { Badge } from "@/components/reui/badge"
import { type Column } from "@tanstack/react-table" import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Column } from "@tanstack/react-table"
import { cn } from "@cfdm/ui/lib/utils" import { cn } from "@cfdm/ui/lib/utils"
import { Button } from "@cfdm/ui/components/button" import { Button } from "@cfdm/ui/components/button"
@@ -11,10 +14,10 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@cfdm/ui/components/popover" } from "@cfdm/ui/components/popover"
import { Separator } from "@cfdm/ui/components/separator" import { Separator } from "@cfdm/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react" import { CheckIcon, CirclePlusIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> { interface DataGridColumnFilterProps<TData extends object, TValue> {
column?: Column<TData, TValue> column?: Column<DataGridFeatures, TData, TValue>
title?: string title?: string
options: { options: {
label: string label: string
@@ -23,13 +26,16 @@ interface DataGridColumnFilterProps<TData, TValue> {
}[] }[]
} }
function DataGridColumnFilter<TData, TValue>({ function DataGridColumnFilter<TData extends object, TValue>({
column, column,
title, title,
options, options,
}: DataGridColumnFilterProps<TData, TValue>) { }: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues() const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[]) const filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue as string[]) : []
)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
@@ -51,16 +57,13 @@ function DataGridColumnFilter<TData, TValue>({
<Separator orientation="vertical" className="mx-2 h-4" /> <Separator orientation="vertical" className="mx-2 h-4" />
<Badge <Badge
variant="secondary" variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden" className="px-1 font-normal lg:hidden"
> >
{selectedValues.size} {selectedValues.size}
</Badge> </Badge>
<div className="hidden space-x-1 lg:flex"> <div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? ( {selectedValues.size > 2 ? (
<Badge <Badge variant="secondary" className="px-1 font-normal">
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected {selectedValues.size} selected
</Badge> </Badge>
) : ( ) : (
@@ -70,7 +73,7 @@ function DataGridColumnFilter<TData, TValue>({
<Badge <Badge
variant="secondary" variant="secondary"
key={option.value} key={option.value}
className="rounded-sm px-1 font-normal" className="px-1 font-normal"
> >
{option.label} {option.label}
</Badge> </Badge>
@@ -100,28 +103,39 @@ function DataGridColumnFilter<TData, TValue>({
<div className="p-1"> <div className="p-1">
{filteredOptions.map((option) => { {filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value) const isSelected = selectedValues.has(option.value)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return ( return (
<div <div
key={option.value} key={option.value}
onClick={() => { role="button"
if (isSelected) { tabIndex={0}
selectedValues.delete(option.value) aria-pressed={isSelected}
} else { onClick={toggleOption}
selectedValues.add(option.value) onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
} }
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}} }}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none", "rounded-md relative flex cursor-pointer items-center gap-2 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" "hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)} )}
> >
<div <div
className={cn( className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border", "border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
isSelected isSelected
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible" : "opacity-50 [&_svg]:invisible"
@@ -130,12 +144,12 @@ function DataGridColumnFilter<TData, TValue>({
<CheckIcon className="h-4 w-4" /> <CheckIcon className="h-4 w-4" />
</div> </div>
{option.icon && ( {option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" /> <option.icon className="text-muted-foreground h-4 w-4" />
)} )}
<span>{option.label}</span> <span>{option.label}</span>
{facets?.get(option.value) && ( {facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs"> <span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)} {facetCount}
</span> </span>
)} )}
</div> </div>
@@ -148,8 +162,16 @@ function DataGridColumnFilter<TData, TValue>({
<div className="bg-border -mx-1 my-1 h-px" /> <div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1"> <div className="p-1">
<div <div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)} 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" onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
> >
Clear filters Clear filters
</div> </div>
@@ -1,11 +1,14 @@
"use client" "use client"
import { type HTMLAttributes, memo, type ReactNode, useMemo } from "react" import { memo, useMemo } from "react"
import type { HTMLAttributes, ReactNode } from "react"
import { import {
getColumnHeaderLabel, getColumnHeaderLabel,
useDataGrid, useDataGrid,
} from "@/components/reui/data-grid/data-grid" } from "@/components/reui/data-grid/data-grid"
import { type Column } from "@tanstack/react-table" import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { Subscribe } from "@tanstack/react-table"
import type { Column } from "@tanstack/react-table"
import { cn } from "@cfdm/ui/lib/utils" import { cn } from "@cfdm/ui/lib/utils"
import { Button } from "@cfdm/ui/components/button" import { Button } from "@cfdm/ui/components/button"
@@ -22,22 +25,23 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@cfdm/ui/components/dropdown-menu" } from "@cfdm/ui/components/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react" import { ArrowDownIcon, ArrowLeftIcon, ArrowLeftToLineIcon, ArrowRightIcon, ArrowRightToLineIcon, ArrowUpIcon, CheckIcon, ChevronsUpDownIcon, PinOffIcon, Settings2Icon } from "lucide-react"
interface DataGridColumnHeaderProps< interface DataGridColumnHeaderProps<
TData, TData extends object,
TValue, TValue,
> extends HTMLAttributes<HTMLDivElement> { > extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue> column: Column<DataGridFeatures, TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */ /** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string title?: string
icon?: ReactNode icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean pinnable?: boolean
filter?: ReactNode filter?: ReactNode
visibility?: boolean visibility?: boolean
} }
function DataGridColumnHeaderInner<TData, TValue>({ function DataGridColumnHeaderInner<TData extends object, TValue>({
column, column,
title, title,
icon, icon,
@@ -45,11 +49,20 @@ function DataGridColumnHeaderInner<TData, TValue>({
filter, filter,
visibility = false, visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) { }: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid() const { isLoading, table, props } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column) const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder // TanStack's columnOrder defaults to [] until a consumer seeds it; fall
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility) // back to the definition order so Move Left/Right work out of the box.
const columnOrderState = table.state.columnOrder
const columnOrder =
columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.state.columnVisibility)
: ""
const isSorted = column.getIsSorted() const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned() const isPinned = column.getIsPinned()
const canSort = column.getCanSort() const canSort = column.getCanSort()
@@ -76,18 +89,18 @@ function DataGridColumnHeaderInner<TData, TValue>({
) )
const headerButtonClassName = cn( 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", "text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
className className
) )
const sortIcon = const sortIcon =
canSort && canSort &&
(isSorted === "desc" ? ( (isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" /> <ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? ( ) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" /> <ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : ( ) : (
<ChevronsUpDownIcon className="mt-px size-3.25" /> <ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
)) ))
const hasControls = const hasControls =
@@ -162,21 +175,21 @@ function DataGridColumnHeaderInner<TData, TValue>({
items.push( items.push(
<DropdownMenuItem <DropdownMenuItem
key="pin-left" key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")} onClick={() => column.pin(isPinned === "start" ? false : "start")}
> >
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" /> <ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span> <span className="grow">Pin to left</span>
{isPinned === "left" && ( {isPinned === "start" && (
<CheckIcon className="text-primary size-4 opacity-100!" /> <CheckIcon className="text-primary size-4 opacity-100!" />
)} )}
</DropdownMenuItem>, </DropdownMenuItem>,
<DropdownMenuItem <DropdownMenuItem
key="pin-right" key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")} onClick={() => column.pin(isPinned === "end" ? false : "end")}
> >
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" /> <ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span> <span className="grow">Pin to right</span>
{isPinned === "right" && ( {isPinned === "end" && (
<CheckIcon className="text-primary size-4 opacity-100!" /> <CheckIcon className="text-primary size-4 opacity-100!" />
)} )}
</DropdownMenuItem> </DropdownMenuItem>
@@ -278,14 +291,14 @@ function DataGridColumnHeaderInner<TData, TValue>({
if (hasControls) { if (hasControls) {
return ( return (
<div className="flex h-full items-center justify-between gap-1.5"> <div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button <Button
variant="ghost" variant="ghost"
className={headerButtonClassName} className={headerButtonClassName}
disabled={isLoading || recordCount === 0} disabled={isLoading}
> >
{icon && icon} {icon && icon}
{resolvedTitle} {resolvedTitle}
@@ -301,7 +314,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
className="-me-1 size-7 rounded-md" className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)} onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`} aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`} title={`Unpin ${resolvedTitle} column`}
@@ -315,11 +328,11 @@ function DataGridColumnHeaderInner<TData, TValue>({
if (canSort || (props.tableLayout?.columnsResizable && canResize)) { if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return ( return (
<div className="flex h-full items-center"> <div className="-ms-2 flex h-full items-center">
<Button <Button
variant="ghost" variant="ghost"
className={headerButtonClassName} className={headerButtonClassName}
disabled={isLoading || recordCount === 0} disabled={isLoading}
onClick={handleSort} onClick={handleSort}
> >
{icon && icon} {icon && icon}
@@ -338,8 +351,47 @@ function DataGridColumnHeaderInner<TData, TValue>({
) )
} }
const DataGridColumnHeader = memo( const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
DataGridColumnHeaderInner TData extends object,
) as typeof DataGridColumnHeaderInner TValue,
>(
props: DataGridColumnHeaderProps<TData, TValue> & {
/** Internal: the state slices the header re-renders on. Not part of the public API. */
subscribedState?: unknown
}
) => ReactNode
/**
* Sort and pin state reaches this header through builder calls on `column`
* (`getIsSorted()`, `getIsPinned()`), and `column` is a stable reference. That
* combination is the one v9's fresh-table-per-state-change does NOT cover:
* React Compiler is free to memoize against the stable column and never
* re-evaluate those reads, which shows up as frozen sort arrows and pin
* controls. The `Subscribe` below turns the slices this header actually reads
* into a real reactive dependency, and threading the selection through as a
* prop is what lets it past the `memo` - which would otherwise see unchanged
* props and skip the render anyway.
*/
function DataGridColumnHeader<TData extends object, TValue>(
props: DataGridColumnHeaderProps<TData, TValue>
) {
const { table } = useDataGrid()
return (
<Subscribe
source={table.store}
selector={(state) => ({
sorting: state.sorting,
columnPinning: state.columnPinning,
columnOrder: state.columnOrder,
columnVisibility: state.columnVisibility,
})}
>
{(subscribed) => (
<DataGridColumnHeaderMemo {...props} subscribedState={subscribed} />
)}
</Subscribe>
)
}
export { DataGridColumnHeader, type DataGridColumnHeaderProps } export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -1,6 +1,9 @@
import { type ReactElement } from "react" "use client"
import type { ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid" import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { type Table } from "@tanstack/react-table" import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import type { Table } from "@tanstack/react-table"
import { import {
DropdownMenu, DropdownMenu,
@@ -11,11 +14,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@cfdm/ui/components/dropdown-menu" } from "@cfdm/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({ function DataGridColumnVisibility<TData extends object>({
table, table,
trigger, trigger,
}: { }: {
table: Table<TData> table: Table<DataGridFeatures, TData>
trigger: ReactElement<Record<string, unknown>> trigger: ReactElement<Record<string, unknown>>
}) { }) {
return ( return (
@@ -24,7 +27,7 @@ function DataGridColumnVisibility<TData>({
<DropdownMenuContent align="end" className="min-w-[150px]"> <DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="font-medium"> <DropdownMenuLabel className="font-medium">
Колонки Toggle Columns
</DropdownMenuLabel> </DropdownMenuLabel>
{table {table
.getAllColumns() .getAllColumns()
@@ -1,6 +1,6 @@
"use client" "use client"
import React, { type ReactNode } from "react" import type { JSX, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid" import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@cfdm/ui/lib/utils" import { cn } from "@cfdm/ui/lib/utils"
@@ -29,53 +29,44 @@ interface DataGridPaginationProps {
rowsPerPageLabel?: string rowsPerPageLabel?: string
previousPageLabel?: string previousPageLabel?: string
nextPageLabel?: string nextPageLabel?: string
pageLabel?: string
previousPagesLabel?: string
nextPagesLabel?: string
ellipsisText?: string ellipsisText?: string
} }
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element { function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
const { table, recordCount, isLoading } = useDataGrid() const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = { const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100], sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />, sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5, moreLimit: 5,
more: false,
info: "{from} - {to} of {count}", info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />, infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page", rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page", previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page", nextPageLabel: "Go to next page",
pageLabel: "Page {page}",
previousPagesLabel: "Previous pages",
nextPagesLabel: "Next pages",
ellipsisText: "...", ellipsisText: "...",
} }
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props } const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm" const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180" const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex const pageIndex = table.state.pagination.pageIndex
const pageSize = table.getState().pagination.pageSize const pageSize = table.state.pagination.pageSize
const from = pageIndex * pageSize + 1 const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount) const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount() const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo // Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info const paginationInfo = mergedProps.info
? mergedProps.info ? mergedProps.info
.replace("{from}", from.toString()) .replaceAll("{from}", from.toString())
.replace("{to}", to.toString()) .replaceAll("{to}", to.toString())
.replace("{count}", recordCount.toString()) .replaceAll("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}` : `${from} - ${to} of ${recordCount}`
// Pagination limit logic // Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5 const paginationMoreLimit = mergedProps.moreLimit || 5
// Determine the start and end of the pagination group // Determine the start and end of the pagination group
const currentGroupStart = const currentGroupStart =
@@ -94,8 +85,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
key={i} key={i}
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
aria-label={mergedProps.pageLabel?.replace("{page}", String(i + 1))}
aria-current={pageIndex === i ? "page" : undefined}
className={cn(btnBaseClasses, "text-muted-foreground", { className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i, "bg-accent text-accent-foreground": pageIndex === i,
})} })}
@@ -120,7 +109,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
size="icon-sm" size="icon-sm"
className={btnBaseClasses} className={btnBaseClasses}
variant="ghost" variant="ghost"
aria-label={mergedProps.previousPagesLabel}
onClick={() => table.setPageIndex(currentGroupStart - 1)} onClick={() => table.setPageIndex(currentGroupStart - 1)}
> >
{mergedProps.ellipsisText} {mergedProps.ellipsisText}
@@ -138,7 +126,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
className={btnBaseClasses} className={btnBaseClasses}
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={mergedProps.nextPagesLabel}
onClick={() => table.setPageIndex(currentGroupEnd)} onClick={() => table.setPageIndex(currentGroupEnd)}
> >
{mergedProps.ellipsisText} {mergedProps.ellipsisText}
@@ -153,12 +140,12 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
data-slot="data-grid-pagination" data-slot="data-grid-pagination"
className={cn( className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0", "flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className mergedProps.className
)} )}
> >
<div className="order-2 flex flex-wrap items-center gap-2.5 pb-2.5 sm:order-1 sm:pb-0"> <div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? ( {isLoading ? (
mergedProps?.sizesSkeleton mergedProps.sizesSkeleton
) : ( ) : (
<> <>
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
@@ -171,11 +158,15 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
table.setPageSize(newPageSize) table.setPageSize(newPageSize)
}} }}
> >
<SelectTrigger className="min-w-18 tabular-nums" size="sm"> <SelectTrigger className="w-16" size="sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="min-w-18"> <SelectContent
{mergedProps?.sizes?.map((size: number) => ( align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}> <SelectItem key={size} value={`${size}`}>
{size} {size}
</SelectItem> </SelectItem>
@@ -187,14 +178,14 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
</div> </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"> <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 ? ( {isLoading ? (
mergedProps?.infoSkeleton mergedProps.infoSkeleton
) : ( ) : (
<> <>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1"> <div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
{paginationInfo} {paginationInfo}
</div> </div>
{pageCount > 1 && ( {pageCount > 1 && (
<div className="order-1 flex items-center gap-1 sm:order-2"> <div className="order-1 flex items-center space-x-1">
<Button <Button
size="icon-sm" size="icon-sm"
variant="ghost" variant="ghost"
@@ -1,11 +1,7 @@
import { "use client"
type PointerEvent,
type ReactNode, import { useCallback, useEffect, useRef, useState } from "react"
useCallback, import type { PointerEvent, ReactNode } from "react"
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid" import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
@@ -23,6 +19,11 @@ const INITIAL_METRICS = {
trackHeight: 0, trackHeight: 0,
} as const } as const
const SCROLLBAR_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"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both" type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = { type ScrollbarMetrics = {
@@ -89,8 +90,9 @@ function DataGridScrollArea({
orientation = "both", orientation = "both",
...props ...props
}: DataGridScrollAreaProps) { }: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid() const { props: dataGridProps, table } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement | null>(null)
const viewportRef = useRef<HTMLDivElement | null>(null) const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{ const dragRef = useRef<{
pointerId: number pointerId: number
@@ -109,6 +111,11 @@ function DataGridScrollArea({
const showVertical = orientation !== "horizontal" const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar = const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky showVertical && !!dataGridProps.tableLayout?.headerSticky
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
// track is inset to span only the scrollable center region between them.
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
const scrollbarInsetStart = isColumnsPinnable ? table.getStartTotalSize() : 0
const scrollbarInsetEnd = isColumnsPinnable ? table.getEndTotalSize() : 0
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] = const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false) useState(false)
@@ -118,12 +125,19 @@ function DataGridScrollArea({
document.body.style.webkitUserSelect = "" document.body.style.webkitUserSelect = ""
}, []) }, [])
const resetMetrics = useCallback(() => { // The overlay is mounted one commit after the sync that detected overflow,
const container = containerRef.current // so it misses that sync's write. Seeding it from the ref callback lands the
// geometry during commit, before the browser paints the track.
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) { if (node) applyMetrics(node, metricsRef.current)
applyMetrics(container, INITIAL_METRICS) }, [])
const resetMetrics = useCallback(() => {
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
metricsRef.current = INITIAL_METRICS metricsRef.current = INITIAL_METRICS
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
} }
setHasCustomVerticalOverflow((prev) => (prev ? false : prev)) setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
@@ -191,8 +205,13 @@ function DataGridScrollArea({
} }
if (!areMetricsEqual(nextMetrics, metricsRef.current)) { if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics metricsRef.current = nextMetrics
// Scoped to the overlay, never to the container. These four properties
// inherit, and thumbTop changes on essentially every scroll frame, so
// writing them on the element that wraps the whole grid invalidates
// computed style for every row and cell each frame. The overlay subtree
// is their only reader.
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
} }
setHasCustomVerticalOverflow((prev) => setHasCustomVerticalOverflow((prev) =>
@@ -213,21 +232,6 @@ function DataGridScrollArea({
return 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 let frame = 0
const scheduleSync = () => { const scheduleSync = () => {
@@ -235,25 +239,69 @@ function DataGridScrollArea({
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar) frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
} }
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer = const observer =
typeof ResizeObserver === "undefined" typeof ResizeObserver === "undefined"
? null ? null
: new ResizeObserver(scheduleSync) : new ResizeObserver(scheduleSync)
const observed = new Set<HTMLElement>()
observer?.observe(viewport) const observeElement = (element: HTMLElement | null) => {
observedElementsRef.current.header && if (element && observer && !observed.has(element)) {
observer?.observe(observedElementsRef.current.header) observer.observe(element)
observedElementsRef.current.table && observed.add(element)
observer?.observe(observedElementsRef.current.table) }
observedElementsRef.current.tableViewport && }
observer?.observe(observedElementsRef.current.tableViewport)
const resolveObservedElements = () => {
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,
}
observeElement(observedElementsRef.current.header)
observeElement(observedElementsRef.current.table)
observeElement(observedElementsRef.current.tableViewport)
return !!(
observedElementsRef.current.header && observedElementsRef.current.table
)
}
observeElement(viewport)
const resolvedOnMount = resolveObservedElements()
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
// A table that mounts after this effect (empty state swapped for data)
// would otherwise never be observed and the custom scrollbar would
// overlap the sticky header. One-shot: disconnects once resolved.
let mutationObserver: MutationObserver | null = null
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => {
if (resolveObservedElements()) {
mutationObserver?.disconnect()
mutationObserver = null
scheduleSync()
}
})
mutationObserver.observe(container, { childList: true, subtree: true })
}
return () => { return () => {
cancelAnimationFrame(frame) cancelAnimationFrame(frame)
observer?.disconnect() observer?.disconnect()
mutationObserver?.disconnect()
viewport.removeEventListener("scroll", scheduleSync) viewport.removeEventListener("scroll", scheduleSync)
clearDragState() clearDragState()
} }
@@ -345,6 +393,10 @@ function DataGridScrollArea({
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area" data-slot="data-grid-scroll-area"
// Styling hook: present while the sticky-header scroll mode detects
// vertical overflow, so consumers can style scrollable vs short
// grids with a plain ancestor attribute selector.
data-overflow-vertical={hasCustomVerticalOverflow ? "true" : undefined}
className={cn("relative", className)} className={cn("relative", className)}
{...props} {...props}
> >
@@ -363,11 +415,19 @@ function DataGridScrollArea({
data-slot="data-grid-scrollbar" data-slot="data-grid-scrollbar"
data-orientation="horizontal" data-orientation="horizontal"
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" className={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
> >
<ScrollAreaPrimitive.Thumb <ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb" data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1" className={SCROLLBAR_THUMB_CLASSNAME}
/> />
</ScrollAreaPrimitive.Scrollbar> </ScrollAreaPrimitive.Scrollbar>
)} )}
@@ -377,11 +437,11 @@ function DataGridScrollArea({
data-slot="data-grid-scrollbar" data-slot="data-grid-scrollbar"
data-orientation="vertical" data-orientation="vertical"
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" className={SCROLLBAR_CLASSNAME}
> >
<ScrollAreaPrimitive.Thumb <ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb" data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1" className={SCROLLBAR_THUMB_CLASSNAME}
/> />
</ScrollAreaPrimitive.Scrollbar> </ScrollAreaPrimitive.Scrollbar>
)} )}
@@ -389,6 +449,7 @@ function DataGridScrollArea({
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && ( {usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div <div
ref={setOverlayRef}
aria-hidden="true" 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)" className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
> >
@@ -2,8 +2,8 @@
import { import {
createContext, createContext,
type CSSProperties, memo,
type ReactNode, useCallback,
useContext, useContext,
useEffect, useEffect,
useId, useId,
@@ -11,15 +11,23 @@ import {
useRef, useRef,
useState, useState,
} from "react" } from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid" import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import { import {
DataGridTableBase, DataGridTableBase,
DataGridTableBody, DataGridTableBody,
DataGridTableBodyRow, DataGridTableBodyRow,
DataGridTableBodyRowCell, DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton, DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell, DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty, DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot, DataGridTableFoot,
DataGridTableHead, DataGridTableHead,
DataGridTableHeadRow, DataGridTableHeadRow,
@@ -31,23 +39,32 @@ import {
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
DragOverlay,
KeyboardSensor, KeyboardSensor,
MouseSensor, MouseSensor,
TouchSensor, TouchSensor,
type UniqueIdentifier,
useSensor, useSensor,
useSensors, useSensors,
type CollisionDetection,
type DragCancelEvent,
type DragEndEvent, type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
type Modifier, type Modifier,
type UniqueIdentifier,
} from "@dnd-kit/core" } from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers" import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import { import {
SortableContext, SortableContext,
sortableKeyboardCoordinates,
useSortable, useSortable,
verticalListSortingStrategy, type SortingStrategy,
} from "@dnd-kit/sortable" } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities" import { CSS } from "@dnd-kit/utilities"
import { type Cell, flexRender, type HeaderGroup, type Row } from "@tanstack/react-table" import { flexRender } from "@tanstack/react-table"
import type { Cell, HeaderGroup, Row } from "@tanstack/react-table"
import { createPortal } from "react-dom"
import { cn } from "@cfdm/ui/lib/utils" import { cn } from "@cfdm/ui/lib/utils"
import { Button } from "@cfdm/ui/components/button" import { Button } from "@cfdm/ui/components/button"
@@ -60,23 +77,68 @@ const SortableRowContext = createContext<Pick<
"attributes" | "listeners" "attributes" | "listeners"
> | null>(null) > | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) { /**
* Tree metadata attached to every sortable row, readable from
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
* drops can be resolved from it without re-deriving the shape of the table.
*/
type DataGridTableDndRowData = {
type: "data-grid-row"
/** Tree depth, 0 for root rows. */
depth: number
/** Index within the parent's children, or within the root rows. */
index: number
/** Parent row id, or null for root rows. */
parentId: string | null
}
/**
* Per-row render slot for drop indicators and depth guides. The returned node
* is positioned over the row, so it never adds a column, shifts striping, or
* gets clipped by a truncating resizable cell.
*/
type DataGridTableDndRowDecoration<TData extends object> = (context: {
row: Row<DataGridFeatures, TData>
isDragging: boolean
isOver: boolean
}) => ReactNode
function DataGridTableDndRowHandle({
className,
disabled,
disabledLabel = "Reordering unavailable",
}: {
className?: string
/**
* Renders the grip inert instead of withdrawing it. A grid that reorders on
* one truth (manual order) and sorts on another cannot honour both at once,
* but dropping the handle entirely collapses the gutter and reads as broken
* rather than as unavailable. Keep the column's shape, mute the control.
*/
disabled?: boolean
/** Announced and shown on hover in place of the drag affordance. */
disabledLabel?: string
}) {
const context = useContext(SortableRowContext) const context = useContext(SortableRowContext)
if (!context) { if (!context || disabled) {
// Fallback if context is not available (shouldn't happen in normal usage)
return ( return (
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className={cn( className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing", "size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
// The Button's own disabled treatment supplies the muting; only the
// cursor needs saying, so the grip reads as unavailable rather than
// merely unresponsive.
disabled && "cursor-not-allowed",
className className
)} )}
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
title={disabled ? disabledLabel : undefined}
disabled disabled
> >
<GripHorizontalIcon <GripHorizontalIcon aria-hidden="true" />
/>
</Button> </Button>
) )
} }
@@ -89,74 +151,366 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing", "size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className className
)} )}
aria-label="Drag to reorder row"
{...context.attributes} {...context.attributes}
{...context.listeners} {...context.listeners}
> >
<GripHorizontalIcon <GripHorizontalIcon aria-hidden="true" />
/>
</Button> </Button>
) )
} }
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) { /**
* The rows do not move while one is being carried.
*
* Sliding the siblings apart opens a gap the carried row could go into, which
* reads well in a list of identical rows and badly in a table: the gap is the
* height of the row you are holding, so with rows of unequal height it never
* matches the slot it claims to be, and the row you picked up slides away from
* where it started - which is exactly the position you need to remember if you
* decide not to drop it.
*
* Holding everything still keeps the origin legible, and nothing is lost: the
* DragOverlay clone follows the pointer and the drop indicator names the seam.
* Pass `verticalListSortingStrategy` as `sortingStrategy` for the old feel.
*/
const holdRowsInPlaceStrategy: SortingStrategy = () => null
function DataGridTableDndRow<TData extends object>({
row,
renderRowDecoration,
dropIndicator = true,
}: {
row: Row<DataGridFeatures, TData>
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
dropIndicator?: boolean
}) {
const rowData: DataGridTableDndRowData = {
type: "data-grid-row",
depth: row.depth,
index: row.index,
parentId: row.getParentRow()?.id ?? null,
}
const { const {
transform, transform,
transition,
setNodeRef, setNodeRef,
isDragging, isDragging,
isOver,
attributes, attributes,
listeners, listeners,
index,
activeIndex,
overIndex,
} = useSortable({ } = useSortable({
id: row.id, id: row.id,
data: rowData,
}) })
// Which edge of THIS row the carried row would land on, or null when it is
// not the drop target. Nothing slides apart any more, so the bar is the only
// thing that says where the drop goes: it marks the row at the destination
// index, on the side the carried row comes to rest.
//
// Dragging down it lands after the target, dragging up before it, so the
// edge follows the direction of travel.
const dropEdge =
dropIndicator && activeIndex !== -1 && index === overIndex && !isDragging
? activeIndex < overIndex
? "bottom"
: "top"
: null
const style: CSSProperties = { const style: CSSProperties = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition: transition, // dnd-kit's transition is deliberately dropped. A transition on a transform
opacity: isDragging ? 0.8 : 1, // property of a `tr` does not merely fail to animate in Chrome, it stops the
// transform applying at all: the element sits at the start value forever.
// The drag source escapes it because dnd-kit disables its own transition
// while it is being dragged, which is why the carried row used to be the
// ONLY one that moved and every other row silently refused to open a gap.
// Displacement therefore lands in one step, which is what a table wants.
zIndex: isDragging ? 1 : 0, zIndex: isDragging ? 1 : 0,
position: "relative", position: "relative",
cursor: isDragging ? "grabbing" : undefined, cursor: isDragging ? "grabbing" : undefined,
// The row you are holding is drawn by the DragOverlay, so the one left
// behind is not a second copy of it - it is the slot you came from, and it
// stays exactly where it was. Fading alone read as "this row is busy";
// the outline says "this is the space you are moving out of", which is the
// thing you need if you change your mind mid-drag.
...(isDragging && {
opacity: 0.4,
// Inset so the dashes sit inside the row box and cannot be clipped by
// the neighbouring row's border.
outline: "1px dashed var(--border)",
outlineOffset: "-1px",
}),
} }
const decoration = renderRowDecoration?.({ row, isDragging, isOver })
return ( return (
<SortableRowContext.Provider value={{ attributes, listeners }}> <SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow <DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
row={row} {row
dndRef={setNodeRef} .getVisibleCells()
dndStyle={style} .map((cell: Cell<DataGridFeatures, TData, unknown>, index, cells) => {
key={row.id} return (
> <DataGridTableBodyRowCell cell={cell} key={cell.id}>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => { {flexRender(cell.column.columnDef.cell, cell.getContext())}
return ( {decoration && index === cells.length - 1 ? (
<DataGridTableBodyRowCell cell={cell} key={colIndex}> // Rides inside the last cell rather than in a `td` of its own.
{flexRender(cell.column.columnDef.cell, cell.getContext())} // An absolutely positioned `td` is still a cell as far as table
</DataGridTableBodyRowCell> // layout is concerned, so it added a NINTH column with no width
) // of its own, and under `table-layout: fixed` that new column
})} // swallowed the whole surplus the real columns had been sharing
// — every column snapped back to its declared size and the row's
// content visibly narrowed the moment a drag began. A plain
// element adds no column. It still anchors to the ROW, because
// the row is the nearest positioned ancestor, so the decoration
// spans the full width and is not clipped by the cell.
<div
aria-hidden="true"
data-slot="data-grid-table-row-decoration"
className="pointer-events-none absolute inset-0"
>
{decoration}
</div>
) : null}
{dropEdge && index === cells.length - 1 ? (
// Same anchoring trick as the decoration above: a plain
// element inside the last cell, so it adds no column and
// cannot disturb `table-layout: fixed`. It spans the row
// because the row is the nearest positioned ancestor.
<div
aria-hidden="true"
data-slot="data-grid-table-row-drop-indicator"
data-edge={dropEdge}
className="pointer-events-none absolute inset-0 z-20"
>
{/* Two solid pixels down the leading edge, the same marker
the tree drag uses for its drop target. A wash across
the row has to stay faint enough not to read as a
selected row, and in the achromatic styles primary
carries no chroma at all, so faint plus colourless is
just grey. The bar reads at any weight and leaves the
row's own background to hover and selection.
The bar is the whole indicator: the gap the rows have
already opened says which side, so a rule across the
seam as well only competes with the row borders it sits
between. `data-edge` still carries the direction for
anyone styling their own. */}
<span className="bg-primary absolute inset-y-0 start-0 w-0.5" />
</div>
) : null}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRow> </DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider> </SortableRowContext.Provider>
) )
} }
function DataGridTableDndRows<TData>({ function DataGridTableDndRowsBody<TData extends object>({
table,
dataIds,
renderRowDecoration,
dropIndicator,
sortingStrategy,
}: {
table: DataGridTableInstance<TData>
dataIds: UniqueIdentifier[]
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
dropIndicator?: boolean
sortingStrategy: SortingStrategy
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{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>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<SortableContext items={dataIds} strategy={sortingStrategy}>
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
return (
<DataGridTableDndRow
row={row}
renderRowDecoration={renderRowDecoration}
dropIndicator={dropIndicator}
key={row.id}
/>
)
})}
</SortableContext>
)
}
/**
* Memoized body rows: 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 MemoizedDataGridTableDndRowsBody = memo(
DataGridTableDndRowsBody,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableDndRowsBody
function DataGridTableDndRows<TData extends object>({
handleDragEnd, handleDragEnd,
dataIds, dataIds,
footerContent, footerContent,
collisionDetection = closestCenter,
modifiers,
sortingStrategy = holdRowsInPlaceStrategy,
renderRowDecoration,
dropIndicator = true,
onDragStart,
onDragMove,
onDragOver,
onDragCancel,
}: { }: {
handleDragEnd: (event: DragEndEvent) => void handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[] dataIds: UniqueIdentifier[]
footerContent?: ReactNode footerContent?: ReactNode
/** Overrides the default `closestCenter` strategy. */
collisionDetection?: CollisionDetection
/**
* Replaces the default axis restriction, e.g. drop `restrictToVerticalAxis`
* to allow the horizontal gesture that tree re-parenting relies on. The
* table container clamp is always applied after these, so a dragged row
* cannot leave the grid.
*/
modifiers?: Modifier[]
/**
* Replaces the default `verticalListSortingStrategy`. Return null from a
* strategy to leave every row exactly where it is. A tree needs that: its drop
* is either INTO the hovered row or BETWEEN two rows, and which one it is
* flips as the pointer crosses a single row, so a gap that opens for one and
* shuts for the other flickers the whole surface. Such a caller draws its own
* insertion line instead, and pairs this with a modifier that holds the
* carried row still, since a gap nothing moves into is just a hole.
*/
sortingStrategy?: SortingStrategy
/** Per-row slot for drop indicators and depth guides. */
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
/**
* Draws a line on the seam the carried row would land on. On by default;
* pass `false` when `renderRowDecoration` paints its own insertion affordance
* and the two would compete.
*/
dropIndicator?: boolean
onDragStart?: (event: DragStartEvent) => void
onDragMove?: (event: DragMoveEvent) => void
onDragOver?: (event: DragOverEvent) => void
onDragCancel?: (event: DragCancelEvent) => void
}) { }) {
const { table, isLoading, props } = useDataGrid() const { table, props } = useDataGrid<TData>()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null) const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false) const [isDraggingRow, setIsDraggingRow] = useState(false)
// The overlay is portalled to the document body. dnd-kit renders DragOverlay
// in place, and it positions with `position: fixed` against viewport
// coordinates - so any ancestor that establishes a containing block for fixed
// descendants silently re-anchors it. `content-visibility`, `contain`,
// `transform`, `filter` and `will-change` all do that, and the first two are
// exactly what a card grid uses to defer off-screen work. The clone then
// lands offset by that ancestor's own top/left, and the container clamp
// below mis-clamps too, because its rects are measured in viewport space.
//
// Resolved in an effect rather than read at render so the server and the
// first client render agree. A drag cannot start before hydration, so the
// overlay being absent for one frame costs nothing.
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
useEffect(() => {
setPortalTarget(document.body)
}, [])
// The row being carried, plus the column widths measured off the header the
// moment the drag starts. The clone lives outside the table, so it has no
// columns of its own and has to be told what they are.
const [carried, setCarried] = useState<{
id: UniqueIdentifier
width: number
height: number
columns: number[]
} | null>(null)
const pickUpRow = useCallback((id: UniqueIdentifier) => {
const container = tableContainerRef.current
const head = container?.querySelector("thead tr")
if (!container || !head) {
setCarried(null)
return
}
// The clone has to be exactly as tall as the row it was lifted from.
// A fixed height reads as the grid growing under the pointer the moment
// you pick a row up, and it is wrong in both directions: rows whose
// content wraps are taller than any constant, and dense rows are shorter.
const source = Array.from(
container.querySelectorAll<HTMLElement>("tbody tr[data-row-id]")
).find((candidate) => candidate.dataset.rowId === String(id))
const height = source?.getBoundingClientRect().height ?? 0
// The fill cell is a header-only spacer that soaks up the surplus a column
// resize leaves behind, and the clone renders data cells only. Measuring it
// in would make the clone's table wider than the cells it actually holds,
// and `table-fixed` hands that orphaned width back out across every column
// -- the carried row comes out visibly wider than the row it was lifted
// from. So the width is the sum of what we render, never the header's own.
const columns = Array.from(head.children)
.filter(
(cell) =>
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
)
.map((cell) => cell.getBoundingClientRect().width)
setCarried({
id,
width: columns.reduce((total, width) => total + width, 0),
height,
columns,
})
}, [])
const carriedRow = carried
? table
.getRowModel()
.rows.find((row: Row<DataGridFeatures, TData>) => row.id === carried.id)
: undefined
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, {}), useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}), useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {}) // Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
) )
useEffect(() => { useEffect(() => {
@@ -175,7 +529,7 @@ function DataGridTableDndRows<TData>({
} }
}, [isDraggingRow]) }, [isDraggingRow])
const modifiers = useMemo(() => { const resolvedModifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({ const restrictToTableContainer: Modifier = ({
transform, transform,
draggingNodeRect, draggingNodeRect,
@@ -194,25 +548,48 @@ function DataGridTableDndRows<TData>({
return { return {
...transform, ...transform,
x: Math.max(minX, Math.min(maxX, x)), // The horizontal rail only engages while the default axis restriction
// is in force. A row is exactly as wide as the viewport, so minX and
// maxX both collapse to 0 and clamping x erases it entirely: harmless
// under restrictToVerticalAxis, which zeroes x anyway, but fatal for a
// caller that replaced the restriction precisely to READ x, as a tree
// does to resolve drop depth. Vertical is railed either way, which is
// what actually keeps a dragged row inside the grid.
x: modifiers ? x : Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)), y: Math.max(minY, Math.min(maxY, y)),
} }
} }
return [restrictToVerticalAxis, restrictToTableContainer] // The container clamp is a safety rail rather than a policy, so it stays
}, []) // applied even when the caller replaces the axis restriction.
return [
...(modifiers ?? [restrictToVerticalAxis]),
restrictToTableContainer,
]
}, [modifiers])
return ( return (
<DndContext <DndContext
id={useId()} id={useId()}
collisionDetection={closestCenter} collisionDetection={collisionDetection}
modifiers={modifiers} modifiers={resolvedModifiers}
onDragCancel={() => setIsDraggingRow(false)} onDragCancel={(event) => {
setIsDraggingRow(false)
setCarried(null)
onDragCancel?.(event)
}}
onDragEnd={(event) => { onDragEnd={(event) => {
setIsDraggingRow(false) setIsDraggingRow(false)
setCarried(null)
handleDragEnd(event) handleDragEnd(event)
}} }}
onDragStart={() => setIsDraggingRow(true)} onDragMove={onDragMove}
onDragOver={onDragOver}
onDragStart={(event) => {
setIsDraggingRow(true)
pickUpRow(event.active.id)
onDragStart?.(event)
}}
sensors={sensors} sensors={sensors}
> >
<DataGridTableViewport <DataGridTableViewport
@@ -227,38 +604,43 @@ function DataGridTableDndRows<TData>({
<DataGridTableHead> <DataGridTableHead>
{table {table
.getHeaderGroups() .getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => { .map(
return ( (headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
<DataGridTableHeadRow headerGroup={headerGroup} key={index}> return (
{headerGroup.headers.map((header, index) => { <DataGridTableHeadRow key={index} rowId={headerGroup.id}>
const { column } = header {headerGroup.headers.map((header, index) => {
const { column } = header
return ( return (
<DataGridTableHeadRowCell header={header} key={index}> <DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout {header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? ( ?.columnsResizable && column.getCanResize() ? (
<div className="truncate"> <>
{flexRender( {flexRender(
header.column.columnDef.header,
header.getContext()
)}
</>
) : (
flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext() header.getContext()
)} )
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)} )}
</DataGridTableHeadRowCell> {props.tableLayout?.columnsResizable &&
) column.getCanResize() && (
})} <DataGridTableHeadRowCellResize
</DataGridTableHeadRow> header={header}
) />
})} )}
</DataGridTableHeadRowCell>
)
})}
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
}
)}
</DataGridTableHead> </DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
@@ -266,35 +648,13 @@ function DataGridTableDndRows<TData>({
)} )}
<DataGridTableBody> <DataGridTableBody>
{props.loadingMode === "skeleton" && <MemoizedDataGridTableDndRowsBody
isLoading && table={table}
pagination?.pageSize ? ( dataIds={dataIds}
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => ( renderRowDecoration={renderRowDecoration}
<DataGridTableBodyRowSkeleton key={rowIndex}> dropIndicator={dropIndicator}
{table.getVisibleFlatColumns().map((column, colIndex) => { sortingStrategy={sortingStrategy}
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> </DataGridTableBody>
{footerContent && ( {footerContent && (
@@ -302,8 +662,75 @@ function DataGridTableDndRows<TData>({
)} )}
</DataGridTableBase> </DataGridTableBase>
</DataGridTableViewport> </DataGridTableViewport>
{/* The row you are actually holding. It is a real clone rendered outside
the table, which is the only way a dragged row can follow the pointer
without disturbing the grid: it adds no cell, so it cannot alter the
column widths, and it floats above the rows rather than through them.
Its presence also tells dnd-kit to stop translating the source row, so
the row left behind simply dims in place.
Portalled to the body so the fixed positioning resolves against the
viewport wherever the grid is mounted. React context crosses a portal,
so DndContext still reaches it. */}
{portalTarget
? createPortal(
<DragOverlay dropAnimation={null}>
{carried && carriedRow ? (
<table
aria-hidden="true"
style={{ width: carried.width, tableLayout: "fixed" }}
className="bg-background border-border pointer-events-none cursor-grabbing rounded-md border shadow-lg"
>
<tbody>
{/* Padding rides on the inner element, not the cell. A `td` can
never render narrower than its own horizontal padding, so a
column resized below that would silently widen here and the
clone would stop matching the row it came from. Height comes
from the measured source row for the same reason the widths
do: the clone has no row of its own to inherit it from. */}
<tr
style={{ height: carried.height || undefined }}
className="[&>td]:p-0 [&>td]:align-middle"
>
{carriedRow
.getVisibleCells()
.map(
(
cell: Cell<DataGridFeatures, TData, unknown>,
index: number
) => (
<td
key={cell.id}
// Falls back to the column's own size so an unforeseen
// header/cell count mismatch degrades to a real width
// rather than to `auto`.
style={{
width:
carried.columns[index] ??
cell.column.getSize(),
}}
>
<div className="truncate px-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
</td>
)
)}
</tr>
</tbody>
</table>
) : null}
</DragOverlay>,
portalTarget
)
: null}
</DndContext> </DndContext>
) )
} }
export { DataGridTableDndRowHandle, DataGridTableDndRows } export { DataGridTableDndRowHandle, DataGridTableDndRows }
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
@@ -1,13 +1,20 @@
"use client"
import { import {
type CSSProperties,
Fragment, Fragment,
type ReactNode, memo,
useEffect, useEffect,
useId, useId,
useMemo,
useRef, useRef,
useState, useState,
} from "react" } from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid" import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import { import {
DataGridTableBase, DataGridTableBase,
DataGridTableBody, DataGridTableBody,
@@ -17,6 +24,8 @@ import {
DataGridTableBodyRowSkeleton, DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell, DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty, DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot, DataGridTableFoot,
DataGridTableHead, DataGridTableHead,
DataGridTableHeadRow, DataGridTableHeadRow,
@@ -29,34 +38,35 @@ import {
closestCenter, closestCenter,
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
type Modifier,
MouseSensor, MouseSensor,
TouchSensor, TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
type Modifier,
} from "@dnd-kit/core" } from "@dnd-kit/core"
import { import {
horizontalListSortingStrategy, horizontalListSortingStrategy,
SortableContext, SortableContext,
sortableKeyboardCoordinates,
useSortable, useSortable,
} from "@dnd-kit/sortable" } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities" import { CSS } from "@dnd-kit/utilities"
import { import { flexRender } from "@tanstack/react-table"
type Cell, import type {
flexRender, Cell,
type Header, Header,
type HeaderGroup, HeaderGroup,
type Row, Row,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Button } from "@cfdm/ui/components/button" import { Button } from "@cfdm/ui/components/button"
import { GripVerticalIcon } from "lucide-react" import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({ function DataGridTableDndHeader<TData extends object>({
header, header,
}: { }: {
header: Header<TData, unknown> header: Header<DataGridFeatures, TData, unknown>
}) { }) {
const { props } = useDataGrid() const { props } = useDataGrid()
const { column } = header const { column } = header
@@ -109,11 +119,11 @@ function DataGridTableDndHeader<TData>({
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" /> <GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button> </Button>
)} )}
<span className="grow truncate"> <div className="grow">
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender(header.column.columnDef.header, header.getContext())} : flexRender(header.column.columnDef.header, header.getContext())}
</span> </div>
{props.tableLayout?.columnsResizable && column.getCanResize() && ( {props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} /> <DataGridTableHeadRowCellResize header={header} />
)} )}
@@ -122,7 +132,11 @@ function DataGridTableDndHeader<TData>({
) )
} }
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) { function DataGridTableDndCell<TData extends object>({
cell,
}: {
cell: Cell<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid() const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({ const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id, id: cell.column.id,
@@ -147,22 +161,93 @@ function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
) )
} }
function DataGridTableDnd<TData>({ function DataGridTableDndBodyRows<TData extends object>({
table,
}: {
table: DataGridTableInstance<TData>
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{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>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<>
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.state.columnOrder}
strategy={horizontalListSortingStrategy}
>
{row
.getVisibleCells()
.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
})}
</>
)
}
/**
* Memoized body rows: 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 MemoizedDataGridTableDndBodyRows = memo(
DataGridTableDndBodyRows,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableDndBodyRows
function DataGridTableDnd<TData extends object>({
handleDragEnd, handleDragEnd,
footerContent, footerContent,
}: { }: {
handleDragEnd: (event: DragEndEvent) => void handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode footerContent?: ReactNode
}) { }) {
const { table, isLoading, props } = useDataGrid() const { table, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false) const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, {}), useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}), useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {}) // Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
) )
useEffect(() => { useEffect(() => {
@@ -182,33 +267,40 @@ function DataGridTableDnd<TData>({
}, [isDraggingColumn]) }, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset // Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => { const modifiers = useMemo(() => {
if (!draggingNodeRect || !containerRef.current) { const restrictToTableBounds: Modifier = ({
return { ...transform, y: 0 } 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
}
} }
const containerRect = containerRef.current.getBoundingClientRect() return [restrictToTableBounds]
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 ( return (
<DndContext <DndContext
collisionDetection={closestCenter} collisionDetection={closestCenter}
id={useId()} id={useId()}
modifiers={[restrictToTableBounds]} modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)} onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => { onDragEnd={(event) => {
setIsDraggingColumn(false) setIsDraggingColumn(false)
@@ -229,23 +321,26 @@ function DataGridTableDnd<TData>({
<DataGridTableHead> <DataGridTableHead>
{table {table
.getHeaderGroups() .getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => { .map(
return ( (headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
<DataGridTableHeadRow headerGroup={headerGroup} key={index}> return (
<SortableContext <DataGridTableHeadRow key={index} rowId={headerGroup.id}>
items={table.getState().columnOrder} <SortableContext
strategy={horizontalListSortingStrategy} items={table.state.columnOrder}
> strategy={horizontalListSortingStrategy}
{headerGroup.headers.map((header) => ( >
<DataGridTableDndHeader {headerGroup.headers.map((header) => (
header={header} <DataGridTableDndHeader
key={header.id} header={header}
/> key={header.id}
))} />
</SortableContext> ))}
</DataGridTableHeadRow> </SortableContext>
) <DataGridTableFillHeadCell />
})} </DataGridTableHeadRow>
)
}
)}
</DataGridTableHead> </DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && ( {(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
@@ -253,51 +348,7 @@ function DataGridTableDnd<TData>({
)} )}
<DataGridTableBody> <DataGridTableBody>
{props.loadingMode === "skeleton" && <MemoizedDataGridTableDndBodyRows table={table} />
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> </DataGridTableBody>
{footerContent && ( {footerContent && (
@@ -1,18 +1,18 @@
"use client" "use client"
import { import { memo, useCallback, useEffect, useRef, useState } from "react"
memo, import type { CSSProperties, ReactNode } from "react"
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid" import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import { import {
DataGridTableBase, DataGridTableBase,
DataGridTableBody, DataGridTableBody,
DataGridTableEmpty, DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot, DataGridTableFoot,
DataGridTableHead, DataGridTableHead,
DataGridTableHeadRow, DataGridTableHeadRow,
@@ -21,14 +21,19 @@ import {
DataGridTableRenderedRow, DataGridTableRenderedRow,
DataGridTableRowSpacer, DataGridTableRowSpacer,
DataGridTableViewport, DataGridTableViewport,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections, getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table" } from "@/components/reui/data-grid/data-grid-table"
import { flexRender, type HeaderGroup, type Row, type Table } from "@tanstack/react-table" import { flexRender } from "@tanstack/react-table"
import { import type { Column, Row } from "@tanstack/react-table"
useVirtualizer, import { useVirtualizer } from "@tanstack/react-virtual"
type VirtualItem, import type {
type Virtualizer, VirtualItem,
type VirtualizerOptions, Virtualizer,
VirtualizerOptions,
} from "@tanstack/react-virtual" } from "@tanstack/react-virtual"
import { cn } from "@cfdm/ui/lib/utils" import { cn } from "@cfdm/ui/lib/utils"
@@ -44,21 +49,226 @@ type DataGridTableVirtualizerInstance = Virtualizer<
HTMLTableRowElement HTMLTableRowElement
> >
type DataGridTableVirtualizerOptions<TData> = Omit< type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
type DataGridTableVirtualScrollRequest = {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
containerElement: HTMLDivElement
headerSticky: boolean
isVirtualizationEnabled: boolean
rowId: string | undefined
rowIndex: number
scrollElement: HTMLElement
}
function isSameDataGridTableScrollRequest(
previous: DataGridTableVirtualScrollRequest | null,
next: DataGridTableVirtualScrollRequest
) {
return (
previous?.align === next.align &&
previous.behavior === next.behavior &&
previous.containerElement === next.containerElement &&
previous.headerSticky === next.headerSticky &&
previous.isVirtualizationEnabled === next.isVirtualizationEnabled &&
previous.rowId === next.rowId &&
previous.rowIndex === next.rowIndex &&
previous.scrollElement === next.scrollElement
)
}
function getDataGridTableScrollTarget({
align,
clientHeight,
rowBottom,
rowHeight,
rowTop,
scrollHeight,
scrollTop,
viewportTopOffset = 0,
}: {
align: DataGridTableVirtualScrollAlignment
clientHeight: number
rowBottom: number
rowHeight: number
rowTop: number
scrollHeight: number
scrollTop: number
viewportTopOffset?: number
}) {
const visibleHeight = Math.max(0, clientHeight - viewportTopOffset)
const viewportTop = scrollTop + viewportTopOffset
const viewportBottom = scrollTop + clientHeight
const targetTop =
align === "auto"
? rowTop < viewportTop
? rowTop - viewportTopOffset
: rowBottom > viewportBottom
? rowBottom - clientHeight
: null
: align === "start"
? rowTop - viewportTopOffset
: align === "end"
? rowBottom - clientHeight
: rowTop -
viewportTopOffset -
Math.max(0, (visibleHeight - rowHeight) / 2)
if (targetTop === null) return null
return Math.min(
Math.max(0, targetTop),
Math.max(0, scrollHeight - clientHeight)
)
}
function getDataGridTableHeaderOffset({
containerElement,
headerSticky,
scrollElement,
}: {
containerElement: HTMLDivElement
headerSticky: boolean
scrollElement: HTMLElement
}) {
if (!headerSticky) return 0
const headerElement = containerElement.querySelector<HTMLElement>(
':scope > [data-slot="data-grid-table"] > thead'
)
if (!headerElement) return 0
const scrollRect = scrollElement.getBoundingClientRect()
const headerRect = headerElement.getBoundingClientRect()
const headerBottomOffset = headerRect.bottom - scrollRect.top
const overlapsViewportTop =
headerRect.top <= scrollRect.top + 0.5 && headerBottomOffset > 0
if (!overlapsViewportTop) return 0
return Math.min(scrollElement.clientHeight, Math.max(0, headerBottomOffset))
}
function scrollDataGridTableToOffset({
behavior,
scrollElement,
targetTop,
virtualizer,
}: {
behavior: ScrollBehavior
scrollElement: HTMLElement
targetTop: number
virtualizer?: DataGridTableVirtualizerInstance
}) {
if (virtualizer) {
virtualizer.scrollToOffset(targetTop, { align: "start", behavior })
} else if (typeof scrollElement.scrollTo === "function") {
scrollElement.scrollTo({ behavior, top: targetTop })
} else {
scrollElement.scrollTop = targetTop
}
}
function scrollDataGridTableRowIntoView({
align,
behavior,
cancelPendingScroll = false,
containerElement,
headerSticky,
rowIndex,
scrollElement,
virtualizer,
}: {
align: DataGridTableVirtualScrollAlignment
behavior: ScrollBehavior
cancelPendingScroll?: boolean
containerElement: HTMLDivElement | null
headerSticky: boolean
rowIndex: number
scrollElement: HTMLElement | null
virtualizer?: DataGridTableVirtualizerInstance
}) {
if (!containerElement || !scrollElement) return false
const rowElement = containerElement.querySelector<HTMLTableRowElement>(
`:scope > [data-slot="data-grid-table"] > tbody > tr[data-index="${rowIndex}"]`
)
if (!rowElement) return false
const scrollRect = scrollElement.getBoundingClientRect()
const rowRect = rowElement.getBoundingClientRect()
const viewportTopOffset = getDataGridTableHeaderOffset({
containerElement,
headerSticky,
scrollElement,
})
const rowTop = scrollElement.scrollTop + rowRect.top - scrollRect.top
const rowBottom = scrollElement.scrollTop + rowRect.bottom - scrollRect.top
const targetTop = getDataGridTableScrollTarget({
align,
clientHeight: scrollElement.clientHeight,
rowBottom,
rowHeight: rowRect.height || rowElement.offsetHeight,
rowTop,
scrollHeight: scrollElement.scrollHeight,
scrollTop: scrollElement.scrollTop,
viewportTopOffset,
})
if (
targetTop === null ||
Math.abs(targetTop - scrollElement.scrollTop) < 0.5
) {
if (cancelPendingScroll) {
scrollDataGridTableToOffset({
behavior: "auto",
scrollElement,
targetTop: scrollElement.scrollTop,
virtualizer,
})
}
return true
}
scrollDataGridTableToOffset({
behavior,
scrollElement,
targetTop,
virtualizer,
})
return true
}
type DataGridTableVirtualizerOptions<TData extends object> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>, VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement" "count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & { > & {
estimateSize?: (index: number, row: Row<TData>) => number estimateSize?: (index: number, row: Row<DataGridFeatures, TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number getItemKey?: (
index: number,
row: Row<DataGridFeatures, TData>
) => string | number
getScrollElement?: ( getScrollElement?: (
elements: DataGridTableVirtualScrollElements elements: DataGridTableVirtualScrollElements
) => HTMLElement | null ) => HTMLElement | null
} }
interface DataGridTableVirtualProps<TData> { interface DataGridTableVirtualProps<TData extends object> {
height?: number | string height?: number | string
estimateSize?: number estimateSize?: number
overscan?: number overscan?: number
/** Scroll animation used when revealing a controlled target row. */
scrollBehavior?: ScrollBehavior
/** Alignment used when revealing a controlled target row. Defaults to auto. */
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
/** Index within the center (non-pinned) row section to reveal. */
scrollToRowIndex?: number
footerContent?: ReactNode footerContent?: ReactNode
renderHeader?: boolean renderHeader?: boolean
onFetchMore?: () => void onFetchMore?: () => void
@@ -68,12 +278,11 @@ interface DataGridTableVirtualProps<TData> {
virtualizerOptions?: DataGridTableVirtualizerOptions<TData> virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
} }
interface VirtualBodyProps<TData> { interface VirtualBodyProps<TData extends object> {
table: Table<TData> table: DataGridTableInstance<TData>
columnCount: number topRows: Row<DataGridFeatures, TData>[]
topRows: Row<TData>[] centerRows: Row<DataGridFeatures, TData>[]
centerRows: Row<TData>[] bottomRows: Row<DataGridFeatures, TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[] virtualItems: VirtualItem[]
totalSize: number totalSize: number
isVirtualizationEnabled: boolean isVirtualizationEnabled: boolean
@@ -85,49 +294,140 @@ interface VirtualBodyProps<TData> {
measureRowRef?: (element: HTMLTableRowElement | null) => void measureRowRef?: (element: HTMLTableRowElement | null) => void
} }
function DataGridTableVirtualSpacer({ function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
columnCount, column,
}: {
column: Column<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastStartPinned =
isPinned === "start" && column.getIsLastColumn("start")
const isFirstEndPinned = isPinned === "end" && column.getIsFirstColumn("end")
return (
<td
aria-hidden="true"
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastStartPinned ? "start" : isFirstEndPinned ? "end" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=end][data-last-col=end]]:shadow-[inset_1px_0_0_0_var(--border)] [&[data-pinned=start][data-last-col=start]]:shadow-[inset_-1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData extends object>({
table,
children,
centerCellClassName,
centerCellStyle,
rowClassName,
ariaHidden,
}: {
table: DataGridTableInstance<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
rowClassName?: string
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getStartVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getEndVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
{leftVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
<td
colSpan={Math.max(centerVisibleColumns.length, 1)}
className={centerCellClassName}
style={centerCellStyle}
>
{children}
</td>
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</tr>
)
}
function DataGridTableVirtualSpacer<TData extends object>({
table,
height, height,
}: { }: {
columnCount: number table: DataGridTableInstance<TData>
height: number height: number
}) { }) {
if (height <= 0) return null if (height <= 0) return null
return ( return (
<tr aria-hidden="true"> <DataGridTableVirtualUtilityRow
<td colSpan={columnCount} style={{ height, padding: 0 }} /> table={table}
</tr> ariaHidden
centerCellClassName="p-0"
centerCellStyle={{ height, padding: 0 }}
>
{null}
</DataGridTableVirtualUtilityRow>
) )
} }
function DataGridTableVirtualStatusRow({ function DataGridTableVirtualStatusRow<TData extends object>({
table,
children, children,
className, className,
columnCount,
}: { }: {
table: DataGridTableInstance<TData>
children: ReactNode children: ReactNode
className?: string className?: string
columnCount: number
}) { }) {
return ( return (
<tr> <DataGridTableVirtualUtilityRow
<td table={table}
colSpan={columnCount} centerCellClassName={cn(
className={cn( "text-muted-foreground py-4 text-center text-sm",
"text-muted-foreground py-4 text-center text-sm", className
className )}
)} >
> {children}
{children} </DataGridTableVirtualUtilityRow>
</td>
</tr>
) )
} }
function DataGridTableVirtualBody<TData>({ function DataGridTableVirtualBody<TData extends object>({
table: _table, table,
columnCount,
topRows, topRows,
centerRows, centerRows,
bottomRows, bottomRows,
@@ -141,10 +441,25 @@ function DataGridTableVirtualBody<TData>({
allRowsLoadedMessage, allRowsLoadedMessage,
measureRowRef, measureRowRef,
}: VirtualBodyProps<TData>) { }: VirtualBodyProps<TData>) {
void _table const { isLoading } = useDataGrid()
const totalRows = topRows.length + centerRows.length + bottomRows.length const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty /> if (!totalRows) {
// Initial load must not flash the empty state as if the query returned
// nothing.
if (isLoading) {
return (
<DataGridTableVirtualStatusRow table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
return <DataGridTableEmpty />
}
const hasCenterRows = centerRows.length > 0 const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore const showFetchingRow = isInfiniteMode && isFetchingMore
@@ -181,7 +496,7 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push( renderedRows.push(
<DataGridTableVirtualSpacer <DataGridTableVirtualSpacer
key="virtual-spacer-start" key="virtual-spacer-start"
columnCount={columnCount} table={table}
height={leadingSpacerHeight} height={leadingSpacerHeight}
/> />
) )
@@ -197,6 +512,7 @@ function DataGridTableVirtualBody<TData>({
key={row.id} key={row.id}
row={row} row={row}
rowRef={measureRowRef} rowRef={measureRowRef}
rowIndex={virtualRow.index}
/> />
) )
}) })
@@ -205,23 +521,22 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push( renderedRows.push(
<DataGridTableVirtualSpacer <DataGridTableVirtualSpacer
key="virtual-spacer-end" key="virtual-spacer-end"
columnCount={columnCount} table={table}
height={trailingSpacerHeight} height={trailingSpacerHeight}
/> />
) )
} }
} else { } else {
centerRows.forEach((row) => { centerRows.forEach((row, rowIndex) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />) renderedRows.push(
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
)
}) })
} }
if (showFetchingRow) { if (showFetchingRow) {
renderedRows.push( renderedRows.push(
<DataGridTableVirtualStatusRow <DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
key="virtual-status-loading"
columnCount={columnCount}
>
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" /> <Spinner className="size-4 opacity-60" />
{loadingMoreMessage} {loadingMoreMessage}
@@ -234,7 +549,7 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push( renderedRows.push(
<DataGridTableVirtualStatusRow <DataGridTableVirtualStatusRow
key="virtual-status-complete" key="virtual-status-complete"
columnCount={columnCount} table={table}
className="py-3 text-xs" className="py-3 text-xs"
> >
{allRowsLoadedMessage} {allRowsLoadedMessage}
@@ -266,13 +581,16 @@ function DataGridTableVirtualBody<TData>({
*/ */
const MemoizedVirtualBody = memo( const MemoizedVirtualBody = memo(
DataGridTableVirtualBody, DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn (_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableVirtualBody ) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({ function DataGridTableVirtual<TData extends object>({
height, height,
estimateSize = 48, estimateSize = 48,
overscan = 10, overscan = 10,
scrollBehavior = "auto",
scrollToRowAlign = "auto",
scrollToRowIndex,
footerContent, footerContent,
renderHeader = true, renderHeader = true,
onFetchMore, onFetchMore,
@@ -281,14 +599,13 @@ function DataGridTableVirtual<TData>({
fetchMoreOffset = 0, fetchMoreOffset = 0,
virtualizerOptions, virtualizerOptions,
}: DataGridTableVirtualProps<TData>) { }: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid() const { table, props } = useDataGrid<TData>()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections( const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table, table,
props.tableLayout?.rowsPinnable props.tableLayout?.rowsPinnable
) )
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function" const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] = const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({ useState<DataGridTableVirtualScrollElements>({
@@ -314,10 +631,9 @@ function DataGridTableVirtual<TData>({
const handleViewportRef = useCallback((node: HTMLDivElement | null) => { const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({ setViewportElements({
containerElement: node, containerElement: node,
scrollElement: scrollElement: node
(node?.closest( ? (getDataGridScrollAreaViewport(node) ?? node)
'[data-slot="scroll-area-viewport"]' : null,
) as HTMLElement | null) ?? node,
}) })
}, []) }, [])
@@ -373,10 +689,135 @@ function DataGridTableVirtual<TData>({
isVirtualizationEnabled && customMeasureElement isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement ? virtualizer.measureElement
: undefined : undefined
const resolvedFetchMoreOffset = useMemo( const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
() => Math.max(0, fetchMoreOffset), const scrollToRowId =
[fetchMoreOffset] scrollToRowIndex !== undefined
? centerRows[scrollToRowIndex]?.id
: undefined
const scrollToRowVirtualItem =
isVirtualizationEnabled && scrollToRowIndex !== undefined
? virtualItems.find((item) => item.index === scrollToRowIndex)
: undefined
const pendingScrollToRowIndexRef = useRef<number | null>(null)
const lastScrollRequestRef = useRef<DataGridTableVirtualScrollRequest | null>(
null
) )
// Latch onFetchMore per row count: virtualItems gets a new identity every
// scroll frame, so without it the effect fires duplicate page requests
// before the consumer flips isFetchingMore, and loops at end-of-data when
// hasMore is never set.
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
// Resolve after every commit so a stable getter can expose a replaced ref;
// the request signature prevents duplicate scrolling on ordinary renders.
useEffect(() => {
const previousRequest = lastScrollRequestRef.current
if (
scrollToRowIndex === undefined ||
scrollToRowIndex < 0 ||
scrollToRowIndex >= centerRows.length
) {
pendingScrollToRowIndexRef.current = null
lastScrollRequestRef.current = null
if (previousRequest) {
const scrollElement = resolveScrollElement()
if (scrollElement) {
scrollDataGridTableToOffset({
behavior: "auto",
scrollElement,
targetTop: scrollElement.scrollTop,
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
})
}
}
return
}
const scrollElement = resolveScrollElement()
const containerElement = viewportElements.containerElement
if (!containerElement || !scrollElement) return
const headerSticky = renderHeader && !!props.tableLayout?.headerSticky
const nextRequest: DataGridTableVirtualScrollRequest = {
align: scrollToRowAlign,
behavior: scrollBehavior,
containerElement,
headerSticky,
isVirtualizationEnabled,
rowId: scrollToRowId,
rowIndex: scrollToRowIndex,
scrollElement,
}
if (isSameDataGridTableScrollRequest(previousRequest, nextRequest)) return
pendingScrollToRowIndexRef.current = null
const rowWasHandled = scrollDataGridTableRowIntoView({
align: scrollToRowAlign,
behavior: scrollBehavior,
cancelPendingScroll: previousRequest !== null,
containerElement,
headerSticky,
rowIndex: scrollToRowIndex,
scrollElement,
virtualizer: isVirtualizationEnabled ? virtualizer : undefined,
})
if (rowWasHandled) {
lastScrollRequestRef.current = nextRequest
return
}
if (!isVirtualizationEnabled) return
pendingScrollToRowIndexRef.current = scrollToRowIndex
lastScrollRequestRef.current = nextRequest
virtualizer.scrollToIndex(scrollToRowIndex, {
align: scrollToRowAlign,
behavior: scrollBehavior,
})
})
useEffect(() => {
if (
!isVirtualizationEnabled ||
scrollToRowIndex === undefined ||
pendingScrollToRowIndexRef.current !== scrollToRowIndex ||
!scrollToRowVirtualItem
) {
return
}
const rowWasHandled = scrollDataGridTableRowIntoView({
align: scrollToRowAlign,
behavior: "auto",
cancelPendingScroll: true,
containerElement: viewportElements.containerElement,
headerSticky: renderHeader && !!props.tableLayout?.headerSticky,
rowIndex: scrollToRowIndex,
scrollElement: resolveScrollElement(),
virtualizer,
})
if (rowWasHandled) {
pendingScrollToRowIndexRef.current = null
}
}, [
isVirtualizationEnabled,
props.tableLayout?.headerSticky,
renderHeader,
resolveScrollElement,
scrollToRowAlign,
scrollToRowIndex,
scrollToRowVirtualItem,
virtualizer,
viewportElements.containerElement,
])
useEffect(() => { useEffect(() => {
if ( if (
@@ -391,7 +832,10 @@ function DataGridTableVirtual<TData>({
const lastItem = virtualItems[virtualItems.length - 1] const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return if (!lastItem) return
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) { if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
fetchMoreFiredAtCountRef.current = centerRows.length
onFetchMore?.() onFetchMore?.()
} }
}, [ }, [
@@ -412,35 +856,35 @@ function DataGridTableVirtual<TData>({
style={ style={
usesExternalScrollArea usesExternalScrollArea
? undefined ? undefined
: { height, overflow: "auto", position: "relative" } : {
height,
overflow: "auto",
position: "relative",
// Standalone mode: this node IS the scroll container, so it
// must stay at its parent's width (not the resizable table
// width) or horizontal scrolling becomes impossible.
width: "auto",
}
} }
> >
<DataGridTableBase> <DataGridTableBase>
{renderHeader && ( {renderHeader && (
<DataGridTableHead> <DataGridTableHead>
{table {mergedHeaderGroups.map((headerGroup) => (
.getHeaderGroups() <DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
.map((headerGroup: HeaderGroup<TData>, index) => ( {headerGroup.headers
<DataGridTableHeadRow headerGroup={headerGroup} key={index}> .filter((header) => header.column.getIsPinned() !== "end")
{headerGroup.headers.map((header, hIndex) => { .map((header) => {
const { column } = header const { column } = header
return ( return (
<DataGridTableHeadRowCell header={header} key={hIndex}> <DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder ? null : props.tableLayout {header.isPlaceholder
?.columnsResizable && column.getCanResize() ? ( ? null
<div className="truncate"> : flexRender(
{flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext() header.getContext()
)} )}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable && {props.tableLayout?.columnsResizable &&
column.getCanResize() && ( column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} /> <DataGridTableHeadRowCellResize header={header} />
@@ -448,8 +892,36 @@ function DataGridTableVirtual<TData>({
</DataGridTableHeadRowCell> </DataGridTableHeadRowCell>
) )
})} })}
</DataGridTableHeadRow> {props.tableLayout?.columnsResizable &&
))} hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "end")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead> </DataGridTableHead>
)} )}
@@ -461,7 +933,6 @@ function DataGridTableVirtual<TData>({
<DataGridTableBody> <DataGridTableBody>
<MemoizedVirtualBody <MemoizedVirtualBody
table={table} table={table}
columnCount={columnCount}
topRows={topRows} topRows={topRows}
centerRows={centerRows} centerRows={centerRows}
bottomRows={bottomRows} bottomRows={bottomRows}
@@ -487,6 +958,7 @@ function DataGridTableVirtual<TData>({
export { DataGridTableVirtual } export { DataGridTableVirtual }
export type { export type {
DataGridTableVirtualScrollAlignment,
DataGridTableVirtualProps, DataGridTableVirtualProps,
DataGridTableVirtualScrollElements, DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions, DataGridTableVirtualizerOptions,
File diff suppressed because it is too large Load Diff
@@ -1,30 +1,155 @@
"use client" "use client"
import { createContext, type ReactNode, useContext, useMemo } from "react" import { createContext, useContext, useEffect, useMemo, useRef } from "react"
import type { ReactNode } from "react"
import { import {
type Column, columnFacetingFeature,
type ColumnFiltersState, columnFilteringFeature,
type RowData, columnOrderingFeature,
type SortingState, columnPinningFeature,
type Table, columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
createExpandedRowModel,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
globalFilteringFeature,
metaHelper,
rowExpandingFeature,
rowPaginationFeature,
rowPinningFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_alphanumericCaseSensitive,
sortFn_basic,
sortFn_datetime,
sortFn_text,
sortFn_textCaseSensitive,
tableFeatures,
} from "@tanstack/react-table"
import type {
Column,
ColumnFiltersState,
ReactTable,
RowData,
SortingState,
Table,
TableFeatures,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { cn } from "@cfdm/ui/lib/utils" import { cn } from "@cfdm/ui/lib/utils"
declare module "@tanstack/react-table" { /**
// eslint-disable-next-line @typescript-eslint/no-unused-vars * Per-column extras the grid reads off `columnDef.meta`.
interface ColumnMeta<TData extends RowData, TValue> { *
headerTitle?: string * TanStack v9 resolves this through the `columnMeta` slot on the feature
headerClassName?: string * bundle below instead of a global `declare module` augmentation, so
cellClassName?: string * installing the data grid no longer widens `ColumnMeta` for every other
skeleton?: ReactNode * table in the consuming app.
expandedContent?: (row: TData) => ReactNode */
} export interface DataGridColumnMeta<TData> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
autoSize?: boolean
} }
/**
* The batteries-included feature bundle every ReUI data-grid example builds
* on. v9 requires each table to declare its features up front, and the grid's
* render path needs the ones registered here: `columnVisibilityFeature` alone
* gates `row.getVisibleCells()`, so even a grid that never hides a column
* needs it to render at all.
*
* Pass it straight through for the full grid:
*
* ```tsx
* const table = useTable({ features: dataGridFeatures, columns, data })
* ```
*
* Extend it when a grid needs more, keeping each prerequisite feature ahead of
* the slot that depends on it:
*
* ```tsx
* const features = tableFeatures({
* ...dataGridFeatures,
* columnGroupingFeature,
* groupedRowModel: createGroupedRowModel(),
* })
* ```
*
* Or drop it entirely and hand `<DataGrid>` a leaner table - the components
* accept any bundle, so you keep full ownership of the TanStack core.
*/
export const dataGridFeatures = tableFeatures({
columnVisibilityFeature,
columnOrderingFeature,
columnPinningFeature,
columnSizingFeature,
// columnResizingFeature requires columnSizingFeature, declared above.
columnResizingFeature,
columnFilteringFeature,
// Powers DataGridColumnFilter's column.getFacetedUniqueValues(). On v8 an
// unregistered facet silently returned an empty map; on v9 the method would
// not exist at all, so the faceted row models below are required, not
// optional.
columnFacetingFeature,
// globalFilteringFeature requires columnFilteringFeature, declared above.
globalFilteringFeature,
rowSortingFeature,
rowPaginationFeature,
rowSelectionFeature,
rowExpandingFeature,
rowPinningFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
expandedRowModel: createExpandedRowModel(),
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
// Every built-in v9 ships. A string `sortFn` resolves against this map
// alone, and `sortFn: "auto"` infers a name ("alphanumeric", "text" or
// "datetime") from the first row's value - so a partial map makes auto
// sorting warn and silently fall back on ordinary string columns.
sortFns: {
alphanumeric: sortFn_alphanumeric,
alphanumericCaseSensitive: sortFn_alphanumericCaseSensitive,
basic: sortFn_basic,
datetime: sortFn_datetime,
text: sortFn_text,
textCaseSensitive: sortFn_textCaseSensitive,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
columnMeta: metaHelper<DataGridColumnMeta<any>>(),
})
/** The feature set `dataGridFeatures` registers. */
export type DataGridFeatures = typeof dataGridFeatures
/**
* The grid's internal view of the table.
*
* `TFeatures` is invariant in v9 and an unresolved generic one collapses to a
* union that includes the bare core arm, so no generic signature can call
* `getVisibleCells()`, `getStartVisibleLeafColumns()` and friends. The public
* components stay generic so consumers can pass any bundle they like; the
* table is widened to this concrete type exactly once, on the way into
* context, and every internal component reads it from there.
*/
export type DataGridTableInstance<TData extends object> = ReactTable<
DataGridFeatures,
TData
>
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */ /** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>( export function getColumnHeaderLabel<TData extends RowData, TValue>(
column: Column<TData, TValue> column: Column<DataGridFeatures, TData, TValue>
): string { ): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle if (typeof meta?.headerTitle === "string") return meta.headerTitle
@@ -50,11 +175,115 @@ export type DataGridApiResponse<T> = {
} }
} }
/**
* Everything `<DataGrid>` accepts except the two props the provider consumes
* itself. Kept feature-agnostic: layout and messaging never depend on which
* TanStack features the consumer registered.
*/
export type DataGridLayoutProps<TData extends object> = Omit<
DataGridProps<TableFeatures, TData>,
"table" | "children"
>
export interface DataGridContextProps<TData extends object> { export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData> props: DataGridLayoutProps<TData>
table: Table<TData> table: DataGridTableInstance<TData>
recordCount: number recordCount: number
isLoading: boolean isLoading: boolean
/**
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
* so every table variant and viewport instance shares one application state.
*/
autoSize?: DataGridAutoSizeController
}
export type DataGridAutoSizeController = {
/**
* Grows the first visible `meta.autoSize` column by the given free space.
* Applies at most once per column id; safe to call from every viewport
* measurement. Returns true when a sizing update was dispatched.
*/
apply: (fillWidth: number) => boolean
}
function createDataGridAutoSizeController<TData extends object>(
/**
* A getter, not the table itself.
*
* v8 handed back one stable table whose state mutated in place, so a
* controller could close over it. v9 returns a NEW table wrapper on every
* state change, and a captured one keeps reporting the state it was built
* with - here that meant `columnSizing` looked permanently empty, the
* applied-once guard re-armed on every measurement, and the fill overwrote
* whatever width the user had just dragged the column to.
*/
getTable: () => DataGridTableInstance<TData>
): DataGridAutoSizeController {
let applied: { columnId: string; base: number; grown: number } | null = null
return {
apply(fillWidth: number) {
const table = getTable()
const columnSizing = table.state.columnSizing
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
// controlled state replacement) so the column re-fills instead of
// leaving a dead blank strip.
if (applied && columnSizing[applied.columnId] === undefined) {
applied = null
}
if (fillWidth <= 0) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
return false
}
// A width this coordinator did not write belongs to someone else -
// almost always the user, who just dragged the column's resize handle.
// Filling over it is what made a `meta.autoSize` column look
// un-resizable: the drag committed, the next viewport measurement
// stamped the fill back on top, and the column snapped to its old width.
//
// Deliberately keyed on observed state rather than on `applied`, which
// is per-coordinator memory: anything that rebuilds the coordinator
// (a remount, a new table store) forgets what it did, and the guard has
// to survive that. An explicit reset clears the entry and re-arms the
// fill, which is what makes double-click-to-reset still work.
const currentSize = columnSizing[autoSizeColumn.id]
if (currentSize !== undefined && currentSize !== applied?.grown) {
return false
}
// Candidate switched (e.g. the grown column was hidden and another
// meta.autoSize column took over): revert the previous growth if the
// user hasn't manually resized that column since, so visibility
// toggles cannot ratchet the table wider than its container forever.
const revert =
applied && columnSizing[applied.columnId] === applied.grown
? applied
: null
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
const grown = base + fillWidth
applied = { columnId: autoSizeColumn.id, base, grown }
table.setColumnSizing((old) => {
const next = { ...old, [autoSizeColumn.id]: grown }
if (revert && next[revert.columnId] === revert.grown) {
next[revert.columnId] = revert.base
}
return next
})
return true
},
}
} }
export type DataGridRequestParams = { export type DataGridRequestParams = {
@@ -64,9 +293,12 @@ export type DataGridRequestParams = {
columnFilters?: ColumnFiltersState columnFilters?: ColumnFiltersState
} }
export interface DataGridProps<TData extends object> { export interface DataGridProps<
TFeatures extends TableFeatures,
TData extends object,
> {
className?: string className?: string
table?: Table<TData> table?: Table<TFeatures, TData>
recordCount: number recordCount: number
children?: ReactNode children?: ReactNode
onRowClick?: (row: TData) => void onRowClick?: (row: TData) => void
@@ -83,6 +315,7 @@ export interface DataGridProps<TData extends object> {
rowRounded?: boolean rowRounded?: boolean
stripped?: boolean stripped?: boolean
headerBackground?: boolean headerBackground?: boolean
footerBackground?: boolean
headerBorder?: boolean headerBorder?: boolean
headerSticky?: boolean headerSticky?: boolean
width?: "auto" | "fixed" width?: "auto" | "fixed"
@@ -112,8 +345,19 @@ const DataGridContext = createContext<
DataGridContextProps<any> | undefined DataGridContextProps<any> | undefined
>(undefined) >(undefined)
function useDataGrid() { /**
const context = useContext(DataGridContext) * Reads the grid context. Pass `TData` from the calling component when the
* table, a row or a cell is handed on to something typed against that row
* shape: v9 declares `TData` invariant, so the default `any` no longer
* unifies with a concrete row type the way it did on v8.
*/
function useDataGrid<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TData extends object = any,
>(): DataGridContextProps<TData> {
const context = useContext(DataGridContext) as
| DataGridContextProps<TData>
| undefined
if (!context) { if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider") throw new Error("useDataGrid must be used within a DataGridProvider")
} }
@@ -124,38 +368,77 @@ function DataGridProvider<TData extends object>({
children, children,
table, table,
...props ...props
}: DataGridProps<TData> & { table: Table<TData> }) { }: DataGridLayoutProps<TData> & {
const tableState = table.getState() table: DataGridTableInstance<TData>
const resolvedColumnsResizeMode = children?: ReactNode
props.tableLayout?.columnsResizeMode ?? "onEnd" }) {
// Latest-props ref: context reads always resolve fresh props through the
// getter below without the memoized context value depending on unstable
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
// otherwise publish a new context value on every consumer render - at
// mousemove rate during a resize drag, piercing the body-rows memo).
const propsRef = useRef(props)
propsRef.current = props
// Keep resize mode aligned with the DataGrid contract every render so // Same treatment for the table itself, which v9 - unlike v8 - re-creates on
// consumer-level useReactTable options cannot flip it back between drags. // every state change. Depending on it directly would republish the context
if (props.tableLayout?.columnsResizable) { // on each resize tick, which is exactly what the memo below exists to
table.options.columnResizeMode = resolvedColumnsResizeMode // prevent; the getter still hands every consumer the current instance.
} const tableRef = useRef(table)
tableRef.current = table
// Re-assert an explicit tableLayout resize mode so consumer-level useTable
// options cannot flip it back between drags. v9 makes `table.options`
// readonly, so this goes through setOptions in an effect rather than a
// render-phase mutation. Without an explicit mode, the consumer's own
// tanstack columnResizeMode (default "onEnd") is honored.
const resizeMode =
props.tableLayout?.columnsResizable && props.tableLayout.columnsResizeMode
? props.tableLayout.columnsResizeMode
: undefined
useEffect(() => {
if (!resizeMode) return
if (table.options.columnResizeMode === resizeMode) return
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
}, [table, resizeMode])
// One autoSize coordinator per table instance so split header/body viewports
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
// stable for the life of the table, rather than on `table` itself: the
// wrapper is re-created on every state change, and re-creating the
// controller with it would reset its applied-once bookkeeping mid-drag.
const autoSize = useMemo(
() => createDataGridAutoSizeController(() => tableRef.current),
[table.store]
)
const tableState = table.state
// Memoize context value so consumers don't re-render during column resize. // Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables // Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders. // on the <table> element handle width updates without React re-renders.
// ReactNode/function props (messages, onRowClick) are also excluded: they
// are served fresh through the props getter, so unstable inline identities
// cannot invalidate the context value.
const value = useMemo( const value = useMemo(
() => ({ () => ({
props, get props() {
table, return propsRef.current
},
get table() {
return tableRef.current
},
recordCount: props.recordCount, recordCount: props.recordCount,
isLoading: props.isLoading || false, isLoading: props.isLoading || false,
autoSize,
}), }),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[ [
table, autoSize,
props.recordCount, props.recordCount,
props.isLoading, props.isLoading,
props.loadingMode, props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className, props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout), JSON.stringify(props.tableLayout),
@@ -165,6 +448,7 @@ function DataGridProvider<TData extends object>({
tableState.pagination, tableState.pagination,
tableState.columnFilters, tableState.columnFilters,
tableState.rowSelection, tableState.rowSelection,
tableState.rowPinning,
tableState.expanded, tableState.expanded,
tableState.columnVisibility, tableState.columnVisibility,
tableState.columnOrder, tableState.columnOrder,
@@ -174,18 +458,24 @@ function DataGridProvider<TData extends object>({
) )
return ( return (
<DataGridContext.Provider value={value}> // One React context serves every TData, but v9 declares both TFeatures and
// TData invariant, so a `DataGridContextProps<any>` context cannot accept a
// `DataGridContextProps<TData>` value structurally. The erasure happens
// here and is undone by the TData generic on each consumer component.
<DataGridContext.Provider
value={value as unknown as DataGridContextProps<TData>}
>
{children} {children}
</DataGridContext.Provider> </DataGridContext.Provider>
) )
} }
function DataGrid<TData extends object>({ function DataGrid<TFeatures extends TableFeatures, TData extends object>({
children, children,
table, table,
...props ...props
}: DataGridProps<TData>) { }: DataGridProps<TFeatures, TData>) {
const defaultProps: Partial<DataGridProps<TData>> = { const defaultProps: Partial<DataGridProps<TFeatures, TData>> = {
loadingMode: "skeleton", loadingMode: "skeleton",
tableLayout: { tableLayout: {
dense: false, dense: false,
@@ -194,12 +484,14 @@ function DataGrid<TData extends object>({
rowRounded: false, rowRounded: false,
stripped: false, stripped: false,
headerSticky: false, headerSticky: false,
headerBackground: true, headerBackground: false,
footerBackground: false,
headerBorder: true, headerBorder: true,
width: "fixed", width: "fixed",
columnsVisibility: false, columnsVisibility: false,
columnsResizable: false, columnsResizable: false,
columnsResizeMode: "onEnd", // columnsResizeMode has no default on purpose: when unset, the
// consumer's tanstack columnResizeMode (default "onEnd") is honored.
columnsPinnable: false, columnsPinnable: false,
columnsMovable: false, columnsMovable: false,
columnsDraggable: false, columnsDraggable: false,
@@ -210,7 +502,10 @@ function DataGrid<TData extends object>({
base: "", base: "",
header: "", header: "",
headerRow: "", headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs", // z-40 keeps the sticky header above pinned body cells (zIndex 30 in
// getPinningStyles), which would otherwise paint over it while
// scrolling vertically with columnsPinnable enabled.
headerSticky: "sticky top-0 z-40 bg-background/90 backdrop-blur-xs",
body: "", body: "",
bodyRow: "", bodyRow: "",
footer: "", footer: "",
@@ -218,7 +513,7 @@ function DataGrid<TData extends object>({
}, },
} }
const mergedProps: DataGridProps<TData> = { const mergedProps: DataGridProps<TFeatures, TData> = {
...defaultProps, ...defaultProps,
...props, ...props,
tableLayout: { tableLayout: {
@@ -236,8 +531,15 @@ function DataGrid<TData extends object>({
throw new Error('DataGrid requires a "table" prop') throw new Error('DataGrid requires a "table" prop')
} }
// The single widening point. Consumers own the TanStack core and may hand
// over any feature bundle; internals need a concrete one to resolve the
// feature-gated APIs they call, and v9's invariant TFeatures rules out
// expressing that with a generic constraint.
const internalTable = table as unknown as DataGridTableInstance<TData>
const internalProps = mergedProps as unknown as DataGridLayoutProps<TData>
return ( return (
<DataGridProvider table={table} {...mergedProps}> <DataGridProvider table={internalTable} {...internalProps}>
{children} {children}
</DataGridProvider> </DataGridProvider>
) )
@@ -246,25 +548,25 @@ function DataGrid<TData extends object>({
function DataGridContainer({ function DataGridContainer({
children, children,
className, className,
border = true,
}: { }: {
children: ReactNode children: ReactNode
className?: string className?: string
/** Accepted for backwards compatibility; currently has no effect. */
border?: boolean border?: boolean
}) { }) {
return ( return (
<div <div
data-slot="data-grid" data-slot="data-grid"
className={cn( className={cn("w-full overflow-hidden", className)}
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
> >
{children} {children}
</div> </div>
) )
} }
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer } export {
useDataGrid,
DataGridProvider,
DataGrid,
DataGridContainer,
}
+14 -2
View File
@@ -23,7 +23,19 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
partial: 'warning', partial: 'warning',
} }
export function StatusBadge({ status, label }: { status: string; label?: string }) { export function StatusBadge({
status,
label,
size = 'default',
}: {
status: string
label?: string
size?: NonNullable<ComponentProps<typeof Badge>['size']>
}) {
const variant = STATUS_VARIANT[status] ?? 'outline' const variant = STATUS_VARIANT[status] ?? 'outline'
return <Badge variant={variant}>{label ?? status}</Badge> return (
<Badge variant={variant} size={size}>
{label ?? status}
</Badge>
)
} }
@@ -17,7 +17,7 @@ import {
type FilterChip, type FilterChip,
} from '@/components/list-filters-bar' } from '@/components/list-filters-bar'
import type { DataGridColumnVisibilityOption } from '@/lib/data-grid-column-visibility' import type { DataGridColumnVisibilityOption } from '@/lib/data-grid-column-visibility'
import type { VisibilityState } from '@tanstack/react-table' import type { ColumnVisibilityState } from '@tanstack/react-table'
import { import {
NumberField, NumberField,
@@ -56,7 +56,7 @@ interface TariffsFiltersToolbarProps {
shownCount: number shownCount: number
totalCount: number totalCount: number
columnVisibilityOptions?: DataGridColumnVisibilityOption[] columnVisibilityOptions?: DataGridColumnVisibilityOption[]
columnVisibility?: VisibilityState columnVisibility?: ColumnVisibilityState
onColumnVisibilityChange?: (columnId: string, visible: boolean) => void onColumnVisibilityChange?: (columnId: string, visible: boolean) => void
} }
@@ -15,7 +15,7 @@ import { PlusIcon } from 'lucide-react'
import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar' import { ListFiltersBar, type FilterChip } from '@/components/list-filters-bar'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import type { DataGridColumnVisibilityOption } from '@/lib/data-grid-column-visibility' import type { DataGridColumnVisibilityOption } from '@/lib/data-grid-column-visibility'
import type { VisibilityState } from '@tanstack/react-table' import type { ColumnVisibilityState } from '@tanstack/react-table'
import { import {
DateSelector, DateSelector,
@@ -64,7 +64,7 @@ interface VpsFiltersToolbarProps {
shownCount: number shownCount: number
totalCount: number totalCount: number
columnVisibilityOptions?: DataGridColumnVisibilityOption[] columnVisibilityOptions?: DataGridColumnVisibilityOption[]
columnVisibility?: VisibilityState columnVisibility?: ColumnVisibilityState
onColumnVisibilityChange?: (columnId: string, visible: boolean) => void onColumnVisibilityChange?: (columnId: string, visible: boolean) => void
} }
@@ -1,11 +1,11 @@
import type { VisibilityState } from "@tanstack/react-table" import type { ColumnVisibilityState } from "@tanstack/react-table"
import type { DataGridColumn } from "@/components/data-grid-types" import type { DataGridColumn } from "@/components/data-grid-types"
export function loadStoredColumnVisibility(key: string): VisibilityState | undefined { export function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
try { try {
const raw = localStorage.getItem(key) const raw = localStorage.getItem(key)
if (!raw) return undefined if (!raw) return undefined
return JSON.parse(raw) as VisibilityState return JSON.parse(raw) as ColumnVisibilityState
} catch { } catch {
return undefined return undefined
} }
+3 -3
View File
@@ -8,7 +8,7 @@ import { api, ApiError } from '@/lib/api-client'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert' import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { Badge } from '@cfdm/ui/components/badge' import { Badge } from '@cfdm/ui/components/badge'
import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit' import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit'
import type { VisibilityState } from '@tanstack/react-table' import type { ColumnVisibilityState } from '@tanstack/react-table'
import type { DataGridColumn } from '@/components/data-grid-types' import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells' import { dataGridCellStack } from '@/components/data-grid-cells'
import { CrudListPage } from '@/components/crud-list-page' import { CrudListPage } from '@/components/crud-list-page'
@@ -45,7 +45,7 @@ export const Route = createFileRoute('/_auth/tariffs')({
component: TariffsPage, component: TariffsPage,
}) })
const INITIAL_COLUMN_VISIBILITY: VisibilityState = { const INITIAL_COLUMN_VISIBILITY: ColumnVisibilityState = {
location: false, location: false,
country: false, country: false,
datacenterName: false, datacenterName: false,
@@ -59,7 +59,7 @@ function TariffsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions()) const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
const [filters, setFilters] = useState<TariffFiltersState>(buildDefaultTariffFilters()) const [filters, setFilters] = useState<TariffFiltersState>(buildDefaultTariffFilters())
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => ({ const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() => ({
...INITIAL_COLUMN_VISIBILITY, ...INITIAL_COLUMN_VISIBILITY,
...(loadStoredColumnVisibility('tariffs-column-visibility') ?? {}), ...(loadStoredColumnVisibility('tariffs-column-visibility') ?? {}),
})) }))
+2 -2
View File
@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/page-header'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge' import { Badge } from '@cfdm/ui/components/badge'
import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit' import { ResourcePage, columnDefFromDataGrid, loadStoredColumnVisibility, dataGridColumnVisibilityOptions } from '@/components/reui-kit'
import type { VisibilityState } from '@tanstack/react-table' import type { ColumnVisibilityState } from '@tanstack/react-table'
import type { DataGridColumn } from '@/components/data-grid-types' import type { DataGridColumn } from '@/components/data-grid-types'
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells' import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
import { CountryFlag } from '@/components/country-flag' import { CountryFlag } from '@/components/country-flag'
@@ -313,7 +313,7 @@ function VpsPage() {
[customFieldDefs], [customFieldDefs],
) )
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => ({ const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() => ({
...(loadStoredColumnVisibility('vps-column-visibility') ?? {}), ...(loadStoredColumnVisibility('vps-column-visibility') ?? {}),
})) }))
+35 -14
View File
@@ -130,8 +130,8 @@ importers:
specifier: ^1.130.2 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]) 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': '@tanstack/react-table':
specifier: ^8.21.3 specifier: ^9.1.2
version: 8.21.3([email protected]([email protected]))([email protected]) version: 9.1.2([email protected]([email protected]))([email protected])
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: ^3.14.4 specifier: ^3.14.4
version: 3.14.4([email protected]([email protected]))([email protected]) version: 3.14.4([email protected]([email protected]))([email protected])
@@ -1493,18 +1493,23 @@ packages:
react: '>=18.0.0 || >=19.0.0' react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0'
'@tanstack/[email protected]':
resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==}
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]': '@tanstack/[email protected]':
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/react-table@8.21.3': '@tanstack/react-table@9.1.2':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} resolution: {integrity: sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==}
engines: {node: '>=12'} engines: {node: '>=20'}
peerDependencies: peerDependencies:
react: '>=16.8' react: '>=18'
react-dom: '>=16.8'
'@tanstack/[email protected]': '@tanstack/[email protected]':
resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==} resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==}
@@ -1555,12 +1560,15 @@ packages:
resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==}
engines: {node: '>=20.19'} engines: {node: '>=20.19'}
'@tanstack/[email protected]':
resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==}
'@tanstack/[email protected]': '@tanstack/[email protected]':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@tanstack/table-core@8.21.3': '@tanstack/table-core@9.1.2':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} resolution: {integrity: sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==}
engines: {node: '>=12'} engines: {node: '>=20'}
'@tanstack/[email protected]': '@tanstack/[email protected]':
resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==} resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==}
@@ -4517,6 +4525,13 @@ snapshots:
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7([email protected]) react-dom: 19.2.7([email protected])
'@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/store': 0.11.1
react: 19.2.7
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])': '@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@tanstack/store': 0.9.3 '@tanstack/store': 0.9.3
@@ -4524,11 +4539,13 @@ snapshots:
react-dom: 19.2.7([email protected]) react-dom: 19.2.7([email protected])
use-sync-external-store: 1.6.0([email protected]) use-sync-external-store: 1.6.0([email protected])
'@tanstack/react-table@8.21.3([email protected]([email protected]))([email protected])': '@tanstack/react-table@9.1.2([email protected]([email protected]))([email protected])':
dependencies: dependencies:
'@tanstack/table-core': 8.21.3 '@tanstack/react-store': 0.11.1([email protected]([email protected]))([email protected])
'@tanstack/table-core': 9.1.2
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7([email protected]) transitivePeerDependencies:
- react-dom
'@tanstack/[email protected]([email protected]([email protected]))([email protected])': '@tanstack/[email protected]([email protected]([email protected]))([email protected])':
dependencies: dependencies:
@@ -4601,9 +4618,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@tanstack/[email protected]': {}
'@tanstack/[email protected]': {} '@tanstack/[email protected]': {}
'@tanstack/table-core@8.21.3': {} '@tanstack/table-core@9.1.2':
dependencies:
'@tanstack/store': 0.11.1
'@tanstack/[email protected]': {} '@tanstack/[email protected]': {}