feat(ui): перевести DataGrid на ReUI v9 и матрицу блокировок
Docker / build (push) Failing after 26s
Docker / build (push) Failing after 26s
Обновить registry data-grid на TanStack Table v9 и показать статус DPI компактной матрицей по VPS и сервисам. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@tanstack/react-router": "^1.130.2",
|
||||
"@tanstack/react-router-devtools": "^1.130.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@tanstack/react-virtual": "^3.14.4",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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'
|
||||
|
||||
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
|
||||
@@ -95,5 +95,49 @@ describe('groupRunsByService', () => {
|
||||
const groups = groupRunsByService([run()])
|
||||
expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com'])
|
||||
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 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(
|
||||
runs: CensorcheckRunDto[],
|
||||
@@ -68,6 +77,7 @@ export type BlockingServiceRow = {
|
||||
dns: string
|
||||
country: string
|
||||
status: string
|
||||
httpStatus: number | null
|
||||
createdAt: string
|
||||
vpsId: string | null
|
||||
}>
|
||||
@@ -85,6 +95,7 @@ export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRo
|
||||
dns: run.vps?.dns ?? '',
|
||||
country: run.vps?.country ?? '',
|
||||
status: result.status,
|
||||
httpStatus: result.httpStatus,
|
||||
createdAt: run.createdAt,
|
||||
vpsId: run.matchedVpsId,
|
||||
}
|
||||
@@ -103,3 +114,68 @@ export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRo
|
||||
}
|
||||
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 { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
import { ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { columnDefFromDataGrid, ExpandableResourceGrid } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||
import {
|
||||
CENSORCHECK_STATUS_LABELS,
|
||||
formatCheckedAt,
|
||||
formatVpsResources,
|
||||
type CensorcheckRunDto,
|
||||
} from './types'
|
||||
import type { BlockingServiceRow } from './blocking-filters'
|
||||
collectProbeColumns,
|
||||
collectServiceColumns,
|
||||
resultByService,
|
||||
type BlockingServiceRow,
|
||||
} from './blocking-filters'
|
||||
import { StatusMatrixCell } from './status-matrix-cell'
|
||||
import type { CensorcheckRunDto } from './types'
|
||||
|
||||
function SummaryBadges({ run }: { run: CensorcheckRunDto }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center'
|
||||
|
||||
function NestedList({
|
||||
rows,
|
||||
}: {
|
||||
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>[] = [
|
||||
{
|
||||
function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
|
||||
return {
|
||||
key: 'vps',
|
||||
header: 'VPS / IP',
|
||||
headerTitle: 'VPS / IP',
|
||||
icon: ServerIcon,
|
||||
enableHiding: false,
|
||||
enablePinning: true,
|
||||
size: 220,
|
||||
minSize: 180,
|
||||
sortValue: (row) => row.vps?.dns || row.probePublicIp,
|
||||
cell: (row) => {
|
||||
const title = row.vps?.dns || row.probePublicIp
|
||||
@@ -87,68 +44,8 @@ const vpsColumns: DataGridColumn<CensorcheckRunDto>[] = [
|
||||
)
|
||||
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({
|
||||
runs,
|
||||
@@ -159,59 +56,133 @@ export function BlockingVpsGrid({
|
||||
onRowClick: (run: CensorcheckRunDto) => void
|
||||
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 (
|
||||
<ExpandableResourceGrid
|
||||
columns={columnDefFromDataGrid(vpsColumns)}
|
||||
<FrameDataGrid
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={runs}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={runs.length > 10}
|
||||
pinLeftColumnIds={['vps']}
|
||||
horizontalScroll
|
||||
emptyTitle="Нет проверок"
|
||||
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
|
||||
emptyAction={emptyAction}
|
||||
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({
|
||||
groups,
|
||||
runs,
|
||||
onProbeClick,
|
||||
emptyAction,
|
||||
}: {
|
||||
groups: BlockingServiceRow[]
|
||||
runs: CensorcheckRunDto[]
|
||||
onProbeClick: (run: CensorcheckRunDto) => void
|
||||
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 (
|
||||
<ExpandableResourceGrid
|
||||
columns={columnDefFromDataGrid(serviceColumns)}
|
||||
<FrameDataGrid
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={groups}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={groups.length > 10}
|
||||
pinLeftColumnIds={['service']}
|
||||
horizontalScroll
|
||||
emptyTitle="Нет сервисов"
|
||||
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 { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
|
||||
import { CheckRunSheet } from './check-run-sheet'
|
||||
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
|
||||
import { filterCensorcheckRuns, serviceMatrixRows } from './blocking-filters'
|
||||
import {
|
||||
CENSORCHECK_STATUS_LABELS,
|
||||
LAUNCHER_CMD,
|
||||
@@ -119,7 +119,7 @@ export function BlockingPage() {
|
||||
|
||||
const runs = currentQuery.data?.items ?? []
|
||||
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 blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0)
|
||||
@@ -230,7 +230,12 @@ export function BlockingPage() {
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
) : (
|
||||
<BlockingServiceGrid groups={serviceGroups} emptyAction={copyLauncher} />
|
||||
<BlockingServiceGrid
|
||||
groups={serviceGroups}
|
||||
runs={rows}
|
||||
onProbeClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -8,12 +8,16 @@ export interface DataGridColumn<T> {
|
||||
icon?: LucideIcon
|
||||
sortable?: boolean
|
||||
sortValue?: (row: T) => string | number
|
||||
/** TanStack sortingFn; для числовых sortValue — `'basic'`. */
|
||||
/** TanStack v9 `sortFn`; для числовых sortValue — `'basic'`. */
|
||||
sortingFn?: 'auto' | 'alphanumeric' | 'basic' | 'text' | 'datetime'
|
||||
headerTitle?: string
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
enableHiding?: boolean
|
||||
size?: number
|
||||
minSize?: number
|
||||
maxSize?: number
|
||||
enablePinning?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Используйте DataGridColumn */
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getExpandedRowModel,
|
||||
useTable,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
type ColumnVisibilityState,
|
||||
type ExpandedState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
} 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 { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
dataGridFeatures,
|
||||
type DataGridFeatures,
|
||||
type DataGridTableInstance,
|
||||
} 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 { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||
@@ -39,14 +43,16 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
export type DataGridColumnDef<TData extends object> = ColumnDef<
|
||||
DataGridFeatures,
|
||||
TData
|
||||
>
|
||||
|
||||
const PAGINATION_LABELS = {
|
||||
rowsPerPageLabel: 'Строк на странице',
|
||||
info: '{from}–{to} из {count}',
|
||||
previousPageLabel: 'Предыдущая страница',
|
||||
nextPageLabel: 'Следующая страница',
|
||||
pageLabel: 'Страница {page}',
|
||||
previousPagesLabel: 'Предыдущие страницы',
|
||||
nextPagesLabel: 'Следующие страницы',
|
||||
} as const
|
||||
|
||||
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
||||
@@ -55,11 +61,11 @@ function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
|
||||
function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return undefined
|
||||
return JSON.parse(raw) as VisibilityState
|
||||
return JSON.parse(raw) as ColumnVisibilityState
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
@@ -87,7 +93,7 @@ export interface FrameDataGridProps<TData extends object> {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
columns: DataGridColumnDef<TData>[]
|
||||
data: TData[]
|
||||
/** Ключ строки — функция, возвращающая уникальный id. */
|
||||
rowId?: (row: TData, index: number) => string
|
||||
@@ -118,18 +124,22 @@ export interface FrameDataGridProps<TData extends object> {
|
||||
/** Показать picker видимости колонок. */
|
||||
enableColumnVisibility?: boolean
|
||||
/** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */
|
||||
columnVisibility?: VisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||||
columnVisibility?: ColumnVisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<ColumnVisibilityState>
|
||||
/** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */
|
||||
columnVisibilityTrigger?: boolean
|
||||
/** Ключ localStorage для сохранения видимости колонок. */
|
||||
columnVisibilityStorageKey?: string
|
||||
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
|
||||
initialColumnVisibility?: VisibilityState
|
||||
initialColumnVisibility?: ColumnVisibilityState
|
||||
className?: string
|
||||
/** Expandable rows — c-data-grid-8 / https://reui.io/preview/base/components/c-data-grid-8 */
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
getRowCanExpand?: (row: TData) => boolean
|
||||
/** Закрепить колонки слева (ids). Timesheet DNA: https://reui.io/preview/base/data-grid-base-4 */
|
||||
pinLeftColumnIds?: string[]
|
||||
/** Горизонтальный скролл широкой матрицы. */
|
||||
horizontalScroll?: boolean
|
||||
}
|
||||
|
||||
function DataGridSectionHeader({
|
||||
@@ -177,8 +187,10 @@ function FrameDataGridBody<TData extends object>({
|
||||
footerContent,
|
||||
showPagination,
|
||||
enableColumnVisibility,
|
||||
columnsPinnable,
|
||||
horizontalScroll,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<TData>>
|
||||
table: DataGridTableInstance<TData>
|
||||
data: TData[]
|
||||
emptyTitle: string
|
||||
onRowClick?: (row: TData) => void
|
||||
@@ -188,7 +200,15 @@ function FrameDataGridBody<TData extends object>({
|
||||
footerContent?: ReactNode
|
||||
showPagination: boolean
|
||||
enableColumnVisibility: boolean
|
||||
columnsPinnable: boolean
|
||||
horizontalScroll: boolean
|
||||
}) {
|
||||
const tableNode = virtualization ? (
|
||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||
) : (
|
||||
<DataGridTable footerContent={footerContent} />
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
@@ -205,7 +225,7 @@ function FrameDataGridBody<TData extends object>({
|
||||
width: 'auto',
|
||||
columnsVisibility: enableColumnVisibility,
|
||||
columnsResizable: false,
|
||||
columnsPinnable: false,
|
||||
columnsPinnable,
|
||||
columnsMovable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
@@ -215,12 +235,15 @@ function FrameDataGridBody<TData extends object>({
|
||||
}}
|
||||
>
|
||||
<DataGridContainer border={false}>
|
||||
{virtualization ? (
|
||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||
<DataGridTableVirtual height={height} footerContent={footerContent} />
|
||||
{virtualization || horizontalScroll ? (
|
||||
<DataGridScrollArea
|
||||
orientation={virtualization && horizontalScroll ? 'both' : virtualization ? 'vertical' : 'both'}
|
||||
style={virtualization ? { height } : { maxHeight: 'min(70vh, 40rem)' }}
|
||||
>
|
||||
{tableNode}
|
||||
</DataGridScrollArea>
|
||||
) : (
|
||||
<DataGridTable footerContent={footerContent} />
|
||||
tableNode
|
||||
)}
|
||||
</DataGridContainer>
|
||||
{showPagination ? <DataGridPaginationBar /> : null}
|
||||
@@ -258,11 +281,18 @@ export function FrameDataGrid<TData extends object>({
|
||||
className,
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
pinLeftColumnIds,
|
||||
horizontalScroll = false,
|
||||
}: FrameDataGridProps<TData>) {
|
||||
const showPagination = pagination ?? true
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
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
|
||||
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
||||
: undefined
|
||||
@@ -271,89 +301,73 @@ export function FrameDataGrid<TData extends object>({
|
||||
|
||||
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
|
||||
const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility
|
||||
const setColumnVisibility: OnChangeFn<VisibilityState> = isColumnVisibilityControlled
|
||||
const setColumnVisibility: OnChangeFn<ColumnVisibilityState> = isColumnVisibilityControlled
|
||||
? (onColumnVisibilityChange ?? (() => undefined))
|
||||
: setInternalColumnVisibility
|
||||
|
||||
useEffect(() => {
|
||||
setPaginationState((current) => ({
|
||||
pageIndex: showPagination ? current.pageIndex : 0,
|
||||
pageSize: showPagination ? pageSize : Number.POSITIVE_INFINITY,
|
||||
}))
|
||||
}, [pageSize, showPagination])
|
||||
|
||||
useEffect(() => {
|
||||
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
|
||||
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
|
||||
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
|
||||
|
||||
const selectColumn: ColumnDef<TData, unknown> = {
|
||||
const selectColumn: DataGridColumnDef<TData> = {
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
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()}
|
||||
/>
|
||||
),
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { cellClassName: 'w-10' },
|
||||
}
|
||||
|
||||
const expandColumn: ColumnDef<TData, unknown> = {
|
||||
const expandColumn: DataGridColumnDef<TData> = {
|
||||
id: 'expand',
|
||||
header: () => null,
|
||||
cell: ({ 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,
|
||||
cell: ({ row }) => <DataGridTableRowExpand row={row} />,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: {
|
||||
cellClassName: 'w-10',
|
||||
expandedContent,
|
||||
},
|
||||
}
|
||||
|
||||
const tableColumns = [
|
||||
const tableColumns: DataGridColumnDef<TData>[] = [
|
||||
...(expandedContent ? [expandColumn] : []),
|
||||
...(enableRowSelection ? [selectColumn] : []),
|
||||
...columns,
|
||||
]
|
||||
|
||||
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 = useReactTable<TData>({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data,
|
||||
columns: tableColumns,
|
||||
state: {
|
||||
sorting,
|
||||
pagination: paginationState,
|
||||
columnVisibility,
|
||||
expanded,
|
||||
...(enablePinning ? { columnPinning } : {}),
|
||||
...(enableRowSelection ? { rowSelection } : {}),
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPaginationState,
|
||||
onExpandedChange: setExpanded,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: enableRowSelection
|
||||
@@ -368,21 +382,11 @@ export function FrameDataGrid<TData extends object>({
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
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,
|
||||
initialState: enablePinning ? { columnPinning } : undefined,
|
||||
getRowId: rowId ? (row, index) => rowId(row, index) : undefined,
|
||||
getRowCanExpand: expandedContent
|
||||
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
||||
: undefined,
|
||||
enableColumnPinning: pinLastColumn,
|
||||
enableRowSelection,
|
||||
enableHiding: enableColumnVisibility,
|
||||
})
|
||||
@@ -438,6 +442,8 @@ export function FrameDataGrid<TData extends object>({
|
||||
footerContent={footerContent}
|
||||
showPagination={showPagination}
|
||||
enableColumnVisibility={enableColumnVisibility}
|
||||
columnsPinnable={enablePinning}
|
||||
horizontalScroll={horizontalScroll}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -452,9 +458,9 @@ export function FrameDataGrid<TData extends object>({
|
||||
}
|
||||
|
||||
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
|
||||
export function columnDefFromDataGrid<T>(
|
||||
export function columnDefFromDataGrid<T extends object>(
|
||||
cols: DataGridColumn<T>[],
|
||||
): ColumnDef<T, unknown>[] {
|
||||
): DataGridColumnDef<T>[] {
|
||||
return cols.map((c) => {
|
||||
const title = resolveHeaderTitle(c.header, c.headerTitle)
|
||||
const Icon = c.icon
|
||||
@@ -467,7 +473,7 @@ export function columnDefFromDataGrid<T>(
|
||||
accessorFn: c.sortValue
|
||||
? (row: T) => c.sortValue!(row)
|
||||
: (row: T) => (row as Record<string, unknown>)[c.key] as string | number,
|
||||
sortingFn: c.sortingFn ?? 'auto',
|
||||
sortFn: c.sortingFn ?? 'auto',
|
||||
}
|
||||
: {}),
|
||||
header: Icon
|
||||
@@ -482,6 +488,10 @@ export function columnDefFromDataGrid<T>(
|
||||
cell: ({ row }) => c.cell(row.original, row.index),
|
||||
enableSorting: sortable,
|
||||
enableHiding: c.enableHiding ?? true,
|
||||
enablePinning: c.enablePinning,
|
||||
size: c.size,
|
||||
minSize: c.minSize,
|
||||
maxSize: c.maxSize,
|
||||
meta: {
|
||||
headerTitle: title || undefined,
|
||||
cellClassName: c.className,
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
loadStoredColumnVisibility,
|
||||
dataGridColumnVisibilityOptions,
|
||||
type FrameDataGridProps,
|
||||
type DataGridColumnDef,
|
||||
type DataGridColumnVisibilityOption,
|
||||
} from './frame-data-grid'
|
||||
export { ExpandableResourceGrid } from './expandable-resource-grid'
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
@@ -13,7 +9,7 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
||||
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
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 { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
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 {
|
||||
FrameDataGrid,
|
||||
type DataGridColumnDef,
|
||||
type FrameDataGridProps,
|
||||
} from './frame-data-grid'
|
||||
|
||||
@@ -84,7 +81,7 @@ export interface ResourcePageProps<T extends object> extends SimpleGridPassthrou
|
||||
onFiltersChange?: (filters: Filter[]) => void
|
||||
onClearFilters?: () => void
|
||||
getFilterFieldValue?: (item: T, field: string) => unknown
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
columns: DataGridColumnDef<T>[]
|
||||
data: T[]
|
||||
getRowId: (row: T, index?: number) => string
|
||||
isLoading?: boolean
|
||||
@@ -323,7 +320,8 @@ function ResourcePageFiltered<T extends object>({
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId: (row) => getRowId(row),
|
||||
@@ -332,9 +330,6 @@ function ResourcePageFiltered<T extends object>({
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
@@ -399,7 +394,15 @@ function ResourcePageFiltered<T extends object>({
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
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">
|
||||
{!hideHeader ? (
|
||||
@@ -495,7 +498,7 @@ function ResourcePageFiltered<T extends object>({
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
info="{from}–{to} из {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
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 { Button } from "@cfdm/ui/components/button"
|
||||
@@ -11,10 +14,10 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@cfdm/ui/components/popover"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||
import { CheckIcon, CirclePlusIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
interface DataGridColumnFilterProps<TData extends object, TValue> {
|
||||
column?: Column<DataGridFeatures, TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
@@ -23,13 +26,16 @@ interface DataGridColumnFilterProps<TData, TValue> {
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData, TValue>({
|
||||
function DataGridColumnFilter<TData extends object, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
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 filteredOptions = useMemo(() => {
|
||||
@@ -51,16 +57,13 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal lg:hidden"
|
||||
className="px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -70,7 +73,7 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
className="px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
@@ -100,28 +103,39 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
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 (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
onClick={toggleOption}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
toggleOption()
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
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
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
@@ -130,12 +144,12 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{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>
|
||||
{facets?.get(option.value) && (
|
||||
{facetCount !== undefined && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
{facetCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,8 +162,16 @@ function DataGridColumnFilter<TData, TValue>({
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
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
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { type HTMLAttributes, memo, type ReactNode, useMemo } from "react"
|
||||
import { memo, useMemo } from "react"
|
||||
import type { HTMLAttributes, ReactNode } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} 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 { Button } from "@cfdm/ui/components/button"
|
||||
@@ -22,22 +25,23 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} 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<
|
||||
TData,
|
||||
TData extends object,
|
||||
TValue,
|
||||
> 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`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
function DataGridColumnHeaderInner<TData, TValue>({
|
||||
function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
@@ -45,11 +49,20 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
||||
const { isLoading, table, props } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
|
||||
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
|
||||
// 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 isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
@@ -76,18 +89,18 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
|
||||
) : 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 =
|
||||
@@ -162,21 +175,21 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
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" />
|
||||
<span className="grow">Pin to left</span>
|
||||
{isPinned === "left" && (
|
||||
{isPinned === "start" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
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" />
|
||||
<span className="grow">Pin to right</span>
|
||||
{isPinned === "right" && (
|
||||
{isPinned === "end" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
@@ -278,14 +291,14 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
|
||||
if (hasControls) {
|
||||
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>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
@@ -301,7 +314,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="-me-1 size-7 rounded-md"
|
||||
className="rounded-lg -me-1 size-7"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
@@ -315,11 +328,11 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="flex h-full items-center">
|
||||
<div className="-ms-2 flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
disabled={isLoading}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
@@ -338,8 +351,47 @@ function DataGridColumnHeaderInner<TData, TValue>({
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeader = memo(
|
||||
DataGridColumnHeaderInner
|
||||
) as typeof DataGridColumnHeaderInner
|
||||
const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
|
||||
TData extends object,
|
||||
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 }
|
||||
@@ -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 { type Table } from "@tanstack/react-table"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -11,11 +14,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
|
||||
function DataGridColumnVisibility<TData>({
|
||||
function DataGridColumnVisibility<TData extends object>({
|
||||
table,
|
||||
trigger,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
table: Table<DataGridFeatures, TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
return (
|
||||
@@ -24,7 +27,7 @@ function DataGridColumnVisibility<TData>({
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Колонки
|
||||
Toggle Columns
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { type ReactNode } from "react"
|
||||
import type { JSX, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
@@ -29,53 +29,44 @@ interface DataGridPaginationProps {
|
||||
rowsPerPageLabel?: string
|
||||
previousPageLabel?: string
|
||||
nextPageLabel?: string
|
||||
pageLabel?: string
|
||||
previousPagesLabel?: string
|
||||
nextPagesLabel?: string
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
const { table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesLabel: "Show",
|
||||
sizesDescription: "per page",
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
more: false,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
previousPageLabel: "Go to previous page",
|
||||
nextPageLabel: "Go to next page",
|
||||
pageLabel: "Page {page}",
|
||||
previousPagesLabel: "Previous pages",
|
||||
nextPagesLabel: "Next pages",
|
||||
ellipsisText: "...",
|
||||
}
|
||||
|
||||
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 pageIndex = table.getState().pagination.pageIndex
|
||||
const pageSize = table.getState().pagination.pageSize
|
||||
const from = pageIndex * pageSize + 1
|
||||
const pageIndex = table.state.pagination.pageIndex
|
||||
const pageSize = table.state.pagination.pageSize
|
||||
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
const paginationInfo = mergedProps?.info
|
||||
const paginationInfo = mergedProps.info
|
||||
? mergedProps.info
|
||||
.replace("{from}", from.toString())
|
||||
.replace("{to}", to.toString())
|
||||
.replace("{count}", recordCount.toString())
|
||||
.replaceAll("{from}", from.toString())
|
||||
.replaceAll("{to}", to.toString())
|
||||
.replaceAll("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps?.moreLimit || 5
|
||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
@@ -94,8 +85,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
key={i}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={mergedProps.pageLabel?.replace("{page}", String(i + 1))}
|
||||
aria-current={pageIndex === i ? "page" : undefined}
|
||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||
"bg-accent text-accent-foreground": pageIndex === i,
|
||||
})}
|
||||
@@ -120,7 +109,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
aria-label={mergedProps.previousPagesLabel}
|
||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
@@ -138,7 +126,6 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={mergedProps.nextPagesLabel}
|
||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
@@ -153,12 +140,12 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps?.className
|
||||
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 ? (
|
||||
mergedProps?.sizesSkeleton
|
||||
mergedProps.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
@@ -171,11 +158,15 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-18 tabular-nums" size="sm">
|
||||
<SelectTrigger className="w-16" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-18">
|
||||
{mergedProps?.sizes?.map((size: number) => (
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="min-w-(--anchor-width)"
|
||||
>
|
||||
{mergedProps.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
@@ -187,14 +178,14 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps?.infoSkeleton
|
||||
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}
|
||||
</div>
|
||||
{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
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import {
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { PointerEvent, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
@@ -23,6 +19,11 @@ const INITIAL_METRICS = {
|
||||
trackHeight: 0,
|
||||
} 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 ScrollbarMetrics = {
|
||||
@@ -89,8 +90,9 @@ function DataGridScrollArea({
|
||||
orientation = "both",
|
||||
...props
|
||||
}: DataGridScrollAreaProps) {
|
||||
const { props: dataGridProps } = useDataGrid()
|
||||
const { props: dataGridProps, table } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null)
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
@@ -109,6 +111,11 @@ function DataGridScrollArea({
|
||||
const showVertical = orientation !== "horizontal"
|
||||
const usesCustomVerticalScrollbar =
|
||||
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] =
|
||||
useState(false)
|
||||
|
||||
@@ -118,12 +125,19 @@ function DataGridScrollArea({
|
||||
document.body.style.webkitUserSelect = ""
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
// The overlay is mounted one commit after the sync that detected overflow,
|
||||
// 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)) {
|
||||
applyMetrics(container, INITIAL_METRICS)
|
||||
if (node) applyMetrics(node, metricsRef.current)
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||
metricsRef.current = INITIAL_METRICS
|
||||
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||
@@ -191,8 +205,13 @@ function DataGridScrollArea({
|
||||
}
|
||||
|
||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||
applyMetrics(container, 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) =>
|
||||
@@ -213,21 +232,6 @@ function DataGridScrollArea({
|
||||
return
|
||||
}
|
||||
|
||||
observedElementsRef.current = {
|
||||
header: container.querySelector(
|
||||
'[data-slot="data-grid-table"] thead'
|
||||
) as HTMLElement | null,
|
||||
horizontalScrollbar: container.querySelector(
|
||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||
) as HTMLElement | null,
|
||||
table: container.querySelector(
|
||||
'[data-slot="data-grid-table"]'
|
||||
) as HTMLElement | null,
|
||||
tableViewport: container.querySelector(
|
||||
'[data-slot="data-grid-table-viewport"]'
|
||||
) as HTMLElement | null,
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
|
||||
const scheduleSync = () => {
|
||||
@@ -235,25 +239,69 @@ function DataGridScrollArea({
|
||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||
}
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleSync)
|
||||
const observed = new Set<HTMLElement>()
|
||||
|
||||
observer?.observe(viewport)
|
||||
observedElementsRef.current.header &&
|
||||
observer?.observe(observedElementsRef.current.header)
|
||||
observedElementsRef.current.table &&
|
||||
observer?.observe(observedElementsRef.current.table)
|
||||
observedElementsRef.current.tableViewport &&
|
||||
observer?.observe(observedElementsRef.current.tableViewport)
|
||||
const observeElement = (element: HTMLElement | null) => {
|
||||
if (element && observer && !observed.has(element)) {
|
||||
observer.observe(element)
|
||||
observed.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer?.disconnect()
|
||||
mutationObserver?.disconnect()
|
||||
viewport.removeEventListener("scroll", scheduleSync)
|
||||
clearDragState()
|
||||
}
|
||||
@@ -345,6 +393,10 @@ function DataGridScrollArea({
|
||||
<div ref={containerRef} className="relative">
|
||||
<ScrollAreaPrimitive.Root
|
||||
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)}
|
||||
{...props}
|
||||
>
|
||||
@@ -363,11 +415,19 @@ function DataGridScrollArea({
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
style={
|
||||
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
|
||||
? {
|
||||
marginInlineStart: scrollbarInsetStart || undefined,
|
||||
marginInlineEnd: scrollbarInsetEnd || undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
@@ -377,11 +437,11 @@ function DataGridScrollArea({
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
@@ -389,6 +449,7 @@ function DataGridScrollArea({
|
||||
|
||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||
<div
|
||||
ref={setOverlayRef}
|
||||
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)"
|
||||
>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
@@ -11,15 +11,23 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
DataGridFeatures,
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
@@ -31,23 +39,32 @@ import {
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
type UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type CollisionDetection,
|
||||
type DragCancelEvent,
|
||||
type DragEndEvent,
|
||||
type DragMoveEvent,
|
||||
type DragOverEvent,
|
||||
type DragStartEvent,
|
||||
type Modifier,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
type SortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
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 { Button } from "@cfdm/ui/components/button"
|
||||
@@ -60,23 +77,68 @@ const SortableRowContext = createContext<Pick<
|
||||
"attributes" | "listeners"
|
||||
> | 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)
|
||||
|
||||
if (!context) {
|
||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
||||
if (!context || disabled) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"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
|
||||
)}
|
||||
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
|
||||
title={disabled ? disabledLabel : undefined}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</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",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</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 {
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
isOver,
|
||||
attributes,
|
||||
listeners,
|
||||
index,
|
||||
activeIndex,
|
||||
overIndex,
|
||||
} = useSortable({
|
||||
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 = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
// dnd-kit's transition is deliberately dropped. A transition on a transform
|
||||
// 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,
|
||||
position: "relative",
|
||||
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 (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow
|
||||
row={row}
|
||||
dndRef={setNodeRef}
|
||||
dndStyle={style}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<DataGridFeatures, TData, unknown>, index, cells) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{decoration && index === cells.length - 1 ? (
|
||||
// Rides inside the last cell rather than in a `td` of its own.
|
||||
// An absolutely positioned `td` is still a cell as far as table
|
||||
// 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>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</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,
|
||||
dataIds,
|
||||
footerContent,
|
||||
collisionDetection = closestCenter,
|
||||
modifiers,
|
||||
sortingStrategy = holdRowsInPlaceStrategy,
|
||||
renderRowDecoration,
|
||||
dropIndicator = true,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDragOver,
|
||||
onDragCancel,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
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 pagination = table.getState().pagination
|
||||
const { table, props } = useDataGrid<TData>()
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
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(
|
||||
useSensor(MouseSensor, {}),
|
||||
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(() => {
|
||||
@@ -175,7 +529,7 @@ function DataGridTableDndRows<TData>({
|
||||
}
|
||||
}, [isDraggingRow])
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
const resolvedModifiers = useMemo(() => {
|
||||
const restrictToTableContainer: Modifier = ({
|
||||
transform,
|
||||
draggingNodeRect,
|
||||
@@ -194,25 +548,48 @@ function DataGridTableDndRows<TData>({
|
||||
|
||||
return {
|
||||
...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)),
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingRow(false)}
|
||||
collisionDetection={collisionDetection}
|
||||
modifiers={resolvedModifiers}
|
||||
onDragCancel={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
setCarried(null)
|
||||
onDragCancel?.(event)
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
setCarried(null)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingRow(true)}
|
||||
onDragMove={onDragMove}
|
||||
onDragOver={onDragOver}
|
||||
onDragStart={(event) => {
|
||||
setIsDraggingRow(true)
|
||||
pickUpRow(event.active.id)
|
||||
onDragStart?.(event)
|
||||
}}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
@@ -227,38 +604,43 @@ function DataGridTableDndRows<TData>({
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
.map(
|
||||
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize
|
||||
header={header}
|
||||
/>
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillHeadCell />
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
@@ -266,35 +648,13 @@ function DataGridTableDndRows<TData>({
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
<MemoizedDataGridTableDndRowsBody
|
||||
table={table}
|
||||
dataIds={dataIds}
|
||||
renderRowDecoration={renderRowDecoration}
|
||||
dropIndicator={dropIndicator}
|
||||
sortingStrategy={sortingStrategy}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
@@ -302,8 +662,75 @@ function DataGridTableDndRows<TData>({
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
|
||||
@@ -1,13 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
Fragment,
|
||||
type ReactNode,
|
||||
memo,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
DataGridFeatures,
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
@@ -17,6 +24,8 @@ import {
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
@@ -29,34 +38,35 @@ import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
type Modifier,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
type Cell,
|
||||
flexRender,
|
||||
type Header,
|
||||
type HeaderGroup,
|
||||
type Row,
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import type {
|
||||
Cell,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
function DataGridTableDndHeader<TData>({
|
||||
function DataGridTableDndHeader<TData extends object>({
|
||||
header,
|
||||
}: {
|
||||
header: Header<TData, unknown>
|
||||
header: Header<DataGridFeatures, TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { column } = header
|
||||
@@ -109,11 +119,11 @@ function DataGridTableDndHeader<TData>({
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
<div className="grow">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
</div>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<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 { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||
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,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const { table, props } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
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(() => {
|
||||
@@ -182,33 +267,40 @@ function DataGridTableDnd<TData>({
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableBounds: Modifier = ({
|
||||
draggingNodeRect,
|
||||
transform,
|
||||
}) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
return [restrictToTableBounds]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={[restrictToTableBounds]}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
@@ -229,23 +321,26 @@ function DataGridTableDnd<TData>({
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
.map(
|
||||
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
<SortableContext
|
||||
items={table.state.columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DataGridTableFillHeadCell />
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
@@ -253,51 +348,7 @@ function DataGridTableDnd<TData>({
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
<MemoizedDataGridTableDndBodyRows table={table} />
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
DataGridFeatures,
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
@@ -21,14 +21,19 @@ import {
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridScrollAreaViewport,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
getDataGridTableRowSections,
|
||||
getPinningStyles,
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { flexRender, type HeaderGroup, type Row, type Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
type VirtualItem,
|
||||
type Virtualizer,
|
||||
type VirtualizerOptions,
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import type { Column, Row } from "@tanstack/react-table"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import type {
|
||||
VirtualItem,
|
||||
Virtualizer,
|
||||
VirtualizerOptions,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
@@ -44,21 +49,226 @@ type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
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>,
|
||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||
> & {
|
||||
estimateSize?: (index: number, row: Row<TData>) => number
|
||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
||||
estimateSize?: (index: number, row: Row<DataGridFeatures, TData>) => number
|
||||
getItemKey?: (
|
||||
index: number,
|
||||
row: Row<DataGridFeatures, TData>
|
||||
) => string | number
|
||||
getScrollElement?: (
|
||||
elements: DataGridTableVirtualScrollElements
|
||||
) => HTMLElement | null
|
||||
}
|
||||
|
||||
interface DataGridTableVirtualProps<TData> {
|
||||
interface DataGridTableVirtualProps<TData extends object> {
|
||||
height?: number | string
|
||||
estimateSize?: 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
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
@@ -68,12 +278,11 @@ interface DataGridTableVirtualProps<TData> {
|
||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||
}
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
columnCount: number
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
interface VirtualBodyProps<TData extends object> {
|
||||
table: DataGridTableInstance<TData>
|
||||
topRows: Row<DataGridFeatures, TData>[]
|
||||
centerRows: Row<DataGridFeatures, TData>[]
|
||||
bottomRows: Row<DataGridFeatures, TData>[]
|
||||
virtualItems: VirtualItem[]
|
||||
totalSize: number
|
||||
isVirtualizationEnabled: boolean
|
||||
@@ -85,49 +294,140 @@ interface VirtualBodyProps<TData> {
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer({
|
||||
columnCount,
|
||||
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
|
||||
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,
|
||||
}: {
|
||||
columnCount: number
|
||||
table: DataGridTableInstance<TData>
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||
</tr>
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
ariaHidden
|
||||
centerCellClassName="p-0"
|
||||
centerCellStyle={{ height, padding: 0 }}
|
||||
>
|
||||
{null}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow({
|
||||
function DataGridTableVirtualStatusRow<TData extends object>({
|
||||
table,
|
||||
children,
|
||||
className,
|
||||
columnCount,
|
||||
}: {
|
||||
table: DataGridTableInstance<TData>
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columnCount: number
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
centerCellClassName={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table: _table,
|
||||
columnCount,
|
||||
function DataGridTableVirtualBody<TData extends object>({
|
||||
table,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
@@ -141,10 +441,25 @@ function DataGridTableVirtualBody<TData>({
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
void _table
|
||||
const { isLoading } = useDataGrid()
|
||||
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 showFetchingRow = isInfiniteMode && isFetchingMore
|
||||
@@ -181,7 +496,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
@@ -197,6 +512,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
rowIndex={virtualRow.index}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -205,23 +521,22 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
centerRows.forEach((row) => {
|
||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||
centerRows.forEach((row, rowIndex) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-loading"
|
||||
columnCount={columnCount}
|
||||
>
|
||||
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
@@ -234,7 +549,7 @@ function DataGridTableVirtualBody<TData>({
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
columnCount={columnCount}
|
||||
table={table}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
@@ -266,13 +581,16 @@ function DataGridTableVirtualBody<TData>({
|
||||
*/
|
||||
const MemoizedVirtualBody = memo(
|
||||
DataGridTableVirtualBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
||||
) as typeof DataGridTableVirtualBody
|
||||
|
||||
function DataGridTableVirtual<TData>({
|
||||
function DataGridTableVirtual<TData extends object>({
|
||||
height,
|
||||
estimateSize = 48,
|
||||
overscan = 10,
|
||||
scrollBehavior = "auto",
|
||||
scrollToRowAlign = "auto",
|
||||
scrollToRowIndex,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
@@ -281,14 +599,13 @@ function DataGridTableVirtual<TData>({
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid()
|
||||
const { table, props } = useDataGrid<TData>()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
)
|
||||
const columnCount =
|
||||
table.getVisibleFlatColumns().length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
const isInfiniteMode = typeof onFetchMore === "function"
|
||||
const [viewportElements, setViewportElements] =
|
||||
useState<DataGridTableVirtualScrollElements>({
|
||||
@@ -314,10 +631,9 @@ function DataGridTableVirtual<TData>({
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
containerElement: node,
|
||||
scrollElement:
|
||||
(node?.closest(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
) as HTMLElement | null) ?? node,
|
||||
scrollElement: node
|
||||
? (getDataGridScrollAreaViewport(node) ?? node)
|
||||
: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -373,10 +689,135 @@ function DataGridTableVirtual<TData>({
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
? virtualizer.measureElement
|
||||
: undefined
|
||||
const resolvedFetchMoreOffset = useMemo(
|
||||
() => Math.max(0, fetchMoreOffset),
|
||||
[fetchMoreOffset]
|
||||
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
||||
const scrollToRowId =
|
||||
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(() => {
|
||||
if (
|
||||
@@ -391,7 +832,10 @@ function DataGridTableVirtual<TData>({
|
||||
const lastItem = virtualItems[virtualItems.length - 1]
|
||||
if (!lastItem) return
|
||||
|
||||
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
|
||||
|
||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||
fetchMoreFiredAtCountRef.current = centerRows.length
|
||||
onFetchMore?.()
|
||||
}
|
||||
}, [
|
||||
@@ -412,35 +856,35 @@ function DataGridTableVirtual<TData>({
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? 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>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, hIndex) => {
|
||||
{mergedHeaderGroups.map((headerGroup) => (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() !== "end")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
@@ -448,8 +892,36 @@ function DataGridTableVirtual<TData>({
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -461,7 +933,6 @@ function DataGridTableVirtual<TData>({
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
columnCount={columnCount}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
@@ -487,6 +958,7 @@ function DataGridTableVirtual<TData>({
|
||||
|
||||
export { DataGridTableVirtual }
|
||||
export type {
|
||||
DataGridTableVirtualScrollAlignment,
|
||||
DataGridTableVirtualProps,
|
||||
DataGridTableVirtualScrollElements,
|
||||
DataGridTableVirtualizerOptions,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react"
|
||||
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import {
|
||||
type Column,
|
||||
type ColumnFiltersState,
|
||||
type RowData,
|
||||
type SortingState,
|
||||
type Table,
|
||||
columnFacetingFeature,
|
||||
columnFilteringFeature,
|
||||
columnOrderingFeature,
|
||||
columnPinningFeature,
|
||||
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"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
headerTitle?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
}
|
||||
/**
|
||||
* Per-column extras the grid reads off `columnDef.meta`.
|
||||
*
|
||||
* TanStack v9 resolves this through the `columnMeta` slot on the feature
|
||||
* bundle below instead of a global `declare module` augmentation, so
|
||||
* installing the data grid no longer widens `ColumnMeta` for every other
|
||||
* table in the consuming app.
|
||||
*/
|
||||
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`. */
|
||||
export function getColumnHeaderLabel<TData, TValue>(
|
||||
column: Column<TData, TValue>
|
||||
export function getColumnHeaderLabel<TData extends RowData, TValue>(
|
||||
column: Column<DataGridFeatures, TData, TValue>
|
||||
): string {
|
||||
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
||||
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> {
|
||||
props: DataGridProps<TData>
|
||||
table: Table<TData>
|
||||
props: DataGridLayoutProps<TData>
|
||||
table: DataGridTableInstance<TData>
|
||||
recordCount: number
|
||||
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 = {
|
||||
@@ -64,9 +293,12 @@ export type DataGridRequestParams = {
|
||||
columnFilters?: ColumnFiltersState
|
||||
}
|
||||
|
||||
export interface DataGridProps<TData extends object> {
|
||||
export interface DataGridProps<
|
||||
TFeatures extends TableFeatures,
|
||||
TData extends object,
|
||||
> {
|
||||
className?: string
|
||||
table?: Table<TData>
|
||||
table?: Table<TFeatures, TData>
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
@@ -83,6 +315,7 @@ export interface DataGridProps<TData extends object> {
|
||||
rowRounded?: boolean
|
||||
stripped?: boolean
|
||||
headerBackground?: boolean
|
||||
footerBackground?: boolean
|
||||
headerBorder?: boolean
|
||||
headerSticky?: boolean
|
||||
width?: "auto" | "fixed"
|
||||
@@ -112,8 +345,19 @@ const DataGridContext = createContext<
|
||||
DataGridContextProps<any> | 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) {
|
||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
||||
}
|
||||
@@ -124,38 +368,77 @@ function DataGridProvider<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
||||
const tableState = table.getState()
|
||||
const resolvedColumnsResizeMode =
|
||||
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
||||
}: DataGridLayoutProps<TData> & {
|
||||
table: DataGridTableInstance<TData>
|
||||
children?: ReactNode
|
||||
}) {
|
||||
// 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
|
||||
// consumer-level useReactTable options cannot flip it back between drags.
|
||||
if (props.tableLayout?.columnsResizable) {
|
||||
table.options.columnResizeMode = resolvedColumnsResizeMode
|
||||
}
|
||||
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
|
||||
// every state change. Depending on it directly would republish the context
|
||||
// on each resize tick, which is exactly what the memo below exists to
|
||||
// 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.
|
||||
// Column sizing state is intentionally excluded from deps -- CSS variables
|
||||
// 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(
|
||||
() => ({
|
||||
props,
|
||||
table,
|
||||
get props() {
|
||||
return propsRef.current
|
||||
},
|
||||
get table() {
|
||||
return tableRef.current
|
||||
},
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
autoSize,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
table,
|
||||
autoSize,
|
||||
props.recordCount,
|
||||
props.isLoading,
|
||||
props.loadingMode,
|
||||
props.loadingMessage,
|
||||
props.fetchingMoreMessage,
|
||||
props.allRowsLoadedMessage,
|
||||
props.emptyMessage,
|
||||
props.onRowClick,
|
||||
props.className,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableLayout),
|
||||
@@ -165,6 +448,7 @@ function DataGridProvider<TData extends object>({
|
||||
tableState.pagination,
|
||||
tableState.columnFilters,
|
||||
tableState.rowSelection,
|
||||
tableState.rowPinning,
|
||||
tableState.expanded,
|
||||
tableState.columnVisibility,
|
||||
tableState.columnOrder,
|
||||
@@ -174,18 +458,24 @@ function DataGridProvider<TData extends object>({
|
||||
)
|
||||
|
||||
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}
|
||||
</DataGridContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGrid<TData extends object>({
|
||||
function DataGrid<TFeatures extends TableFeatures, TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData>) {
|
||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
||||
}: DataGridProps<TFeatures, TData>) {
|
||||
const defaultProps: Partial<DataGridProps<TFeatures, TData>> = {
|
||||
loadingMode: "skeleton",
|
||||
tableLayout: {
|
||||
dense: false,
|
||||
@@ -194,12 +484,14 @@ function DataGrid<TData extends object>({
|
||||
rowRounded: false,
|
||||
stripped: false,
|
||||
headerSticky: false,
|
||||
headerBackground: true,
|
||||
headerBackground: false,
|
||||
footerBackground: false,
|
||||
headerBorder: true,
|
||||
width: "fixed",
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsResizeMode: "onEnd",
|
||||
// columnsResizeMode has no default on purpose: when unset, the
|
||||
// consumer's tanstack columnResizeMode (default "onEnd") is honored.
|
||||
columnsPinnable: false,
|
||||
columnsMovable: false,
|
||||
columnsDraggable: false,
|
||||
@@ -210,7 +502,10 @@ function DataGrid<TData extends object>({
|
||||
base: "",
|
||||
header: "",
|
||||
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: "",
|
||||
bodyRow: "",
|
||||
footer: "",
|
||||
@@ -218,7 +513,7 @@ function DataGrid<TData extends object>({
|
||||
},
|
||||
}
|
||||
|
||||
const mergedProps: DataGridProps<TData> = {
|
||||
const mergedProps: DataGridProps<TFeatures, TData> = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
tableLayout: {
|
||||
@@ -236,8 +531,15 @@ function DataGrid<TData extends object>({
|
||||
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 (
|
||||
<DataGridProvider table={table} {...mergedProps}>
|
||||
<DataGridProvider table={internalTable} {...internalProps}>
|
||||
{children}
|
||||
</DataGridProvider>
|
||||
)
|
||||
@@ -246,25 +548,25 @@ function DataGrid<TData extends object>({
|
||||
function DataGridContainer({
|
||||
children,
|
||||
className,
|
||||
border = true,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** Accepted for backwards compatibility; currently has no effect. */
|
||||
border?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
border &&
|
||||
"border-border rounded-lg border",
|
||||
className
|
||||
)}
|
||||
className={cn("w-full overflow-hidden", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||
export {
|
||||
useDataGrid,
|
||||
DataGridProvider,
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
}
|
||||
@@ -23,7 +23,19 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
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'
|
||||
return <Badge variant={variant}>{label ?? status}</Badge>
|
||||
return (
|
||||
<Badge variant={variant} size={size}>
|
||||
{label ?? status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type FilterChip,
|
||||
} from '@/components/list-filters-bar'
|
||||
import type { DataGridColumnVisibilityOption } from '@/lib/data-grid-column-visibility'
|
||||
import type { VisibilityState } from '@tanstack/react-table'
|
||||
import type { ColumnVisibilityState } from '@tanstack/react-table'
|
||||
|
||||
import {
|
||||
NumberField,
|
||||
@@ -56,7 +56,7 @@ interface TariffsFiltersToolbarProps {
|
||||
shownCount: number
|
||||
totalCount: number
|
||||
columnVisibilityOptions?: DataGridColumnVisibilityOption[]
|
||||
columnVisibility?: VisibilityState
|
||||
columnVisibility?: ColumnVisibilityState
|
||||
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 { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import type { DataGridColumnVisibilityOption } from '@/lib/data-grid-column-visibility'
|
||||
import type { VisibilityState } from '@tanstack/react-table'
|
||||
import type { ColumnVisibilityState } from '@tanstack/react-table'
|
||||
|
||||
import {
|
||||
DateSelector,
|
||||
@@ -64,7 +64,7 @@ interface VpsFiltersToolbarProps {
|
||||
shownCount: number
|
||||
totalCount: number
|
||||
columnVisibilityOptions?: DataGridColumnVisibilityOption[]
|
||||
columnVisibility?: VisibilityState
|
||||
columnVisibility?: ColumnVisibilityState
|
||||
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"
|
||||
|
||||
export function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
|
||||
export function loadStoredColumnVisibility(key: string): ColumnVisibilityState | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return undefined
|
||||
return JSON.parse(raw) as VisibilityState
|
||||
return JSON.parse(raw) as ColumnVisibilityState
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { api, ApiError } from '@/lib/api-client'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
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 { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { CrudListPage } from '@/components/crud-list-page'
|
||||
@@ -45,7 +45,7 @@ export const Route = createFileRoute('/_auth/tariffs')({
|
||||
component: TariffsPage,
|
||||
})
|
||||
|
||||
const INITIAL_COLUMN_VISIBILITY: VisibilityState = {
|
||||
const INITIAL_COLUMN_VISIBILITY: ColumnVisibilityState = {
|
||||
location: false,
|
||||
country: false,
|
||||
datacenterName: false,
|
||||
@@ -59,7 +59,7 @@ function TariffsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [filters, setFilters] = useState<TariffFiltersState>(buildDefaultTariffFilters())
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => ({
|
||||
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() => ({
|
||||
...INITIAL_COLUMN_VISIBILITY,
|
||||
...(loadStoredColumnVisibility('tariffs-column-visibility') ?? {}),
|
||||
}))
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
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 { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
@@ -313,7 +313,7 @@ function VpsPage() {
|
||||
[customFieldDefs],
|
||||
)
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() => ({
|
||||
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() => ({
|
||||
...(loadStoredColumnVisibility('vps-column-visibility') ?? {}),
|
||||
}))
|
||||
|
||||
|
||||
Generated
+35
-14
@@ -130,8 +130,8 @@ importers:
|
||||
specifier: ^1.130.2
|
||||
version: 1.167.0(@tanstack/react-router@1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/router-core@1.171.13)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
specifier: ^9.1.2
|
||||
version: 9.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.4
|
||||
version: 3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -1493,18 +1493,23 @@ packages:
|
||||
react: '>=18.0.0 || >=19.0.0'
|
||||
react-dom: '>=18.0.0 || >=19.0.0'
|
||||
|
||||
'@tanstack/react-store@0.11.1':
|
||||
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/react-store@0.9.3':
|
||||
resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==}
|
||||
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/react-table@8.21.3':
|
||||
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
|
||||
engines: {node: '>=12'}
|
||||
'@tanstack/react-table@9.1.2':
|
||||
resolution: {integrity: sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
react: '>=18'
|
||||
|
||||
'@tanstack/react-virtual@3.14.4':
|
||||
resolution: {integrity: sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==}
|
||||
@@ -1555,12 +1560,15 @@ packages:
|
||||
resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==}
|
||||
engines: {node: '>=20.19'}
|
||||
|
||||
'@tanstack/store@0.11.1':
|
||||
resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==}
|
||||
|
||||
'@tanstack/store@0.9.3':
|
||||
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
'@tanstack/table-core@9.1.2':
|
||||
resolution: {integrity: sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@tanstack/virtual-core@3.17.2':
|
||||
resolution: {integrity: sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==}
|
||||
@@ -4517,6 +4525,13 @@ snapshots:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@tanstack/react-store@0.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.11.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||
|
||||
'@tanstack/react-store@0.9.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.9.3
|
||||
@@ -4524,11 +4539,13 @@ snapshots:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
use-sync-external-store: 1.6.0(react@19.2.7)
|
||||
|
||||
'@tanstack/react-table@8.21.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@tanstack/react-table@9.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
'@tanstack/react-store': 0.11.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tanstack/table-core': 9.1.2
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
transitivePeerDependencies:
|
||||
- react-dom
|
||||
|
||||
'@tanstack/react-virtual@3.14.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
@@ -4601,9 +4618,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/store@0.11.1': {}
|
||||
|
||||
'@tanstack/store@0.9.3': {}
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
'@tanstack/table-core@9.1.2':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.11.1
|
||||
|
||||
'@tanstack/virtual-core@3.17.2': {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user