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