feat(data-table): rebuild as typed dual-engine virtualized grid#78
feat(data-table): rebuild as typed dual-engine virtualized grid#78pras75299 wants to merge 3 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughDataTable is rewritten from a non-generic v1 to a fully typed generic ChangesDataTable v2 rewrite
Sequence Diagram(s)sequenceDiagram
participant Consumer
participant DataTable
participant DataPipeline
participant VirtualEngine
participant StandardEngine
Consumer->>DataTable: data[], columns[], getRowId, preset, feature props
DataTable->>DataPipeline: debounce search query → filter rows
DataPipeline->>DataPipeline: apply multi-sort rules (inferSortType, compareValues)
DataPipeline->>DataPipeline: separate pinnedRows, paginate
DataTable->>DataTable: resolve engine (virtualized vs standard)
alt virtualized engine
DataTable->>VirtualEngine: computeWindow(scrollTop, viewportHeight, rowHeight, rowCount)
VirtualEngine-->>DataTable: WindowSlice (start, end, padTop, padBottom)
DataTable->>DataTable: render spacer rows + visible slice + aria-rowcount/aria-rowindex
else standard engine (groupBy / rowSpan)
DataTable->>StandardEngine: groupBy → collapsible group headers
DataTable->>StandardEngine: computeRowSpans → merged cells
StandardEngine-->>DataTable: group rows + rowSpan plan
end
DataTable->>DataTable: renderHeader (multi-level, frozen offsets, aria-sort)
DataTable->>DataTable: renderBody (selection, expansion, frozen sticky styles)
DataTable-->>Consumer: rendered table + pagination footer
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (1)
registry/data-table/component.tsx (1)
490-515: MoveonSortcallback outside the state updater.The function passed to
setSortRulesmust be pure. InvokingonSort?.(next)inside it violates this requirement—React may call the updater function multiple times in Strict Mode, causing duplicateonSortinvocations and unpredictable side effects. Calculatenextin the pure updater, then invokeonSortin auseEffecthook or at the event handler level.
registry/data-table/component.tsx#L514: moveonSort?.(next)out of thesetSortRules((prev) => ...)callback.apps/www/components/ui/data-table.tsx#L514: apply the same change.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@registry/data-table/component.tsx` around lines 490 - 515, The toggleSort function in both registry/data-table/component.tsx and apps/www/components/ui/data-table.tsx calls onSort?.(next) inside the setSortRules state updater callback, which violates the requirement that state updaters must be pure functions. This can cause duplicate onSort invocations when React's Strict Mode calls the updater multiple times. Move the onSort?.(next) invocation outside of the setSortRules callback and either place it in a useEffect hook that depends on the sort rules, or invoke it directly in the toggleSort event handler after the state update. Make this change at both anchor site (registry/data-table/component.tsx lines 490-515) and sibling site (apps/www/components/ui/data-table.tsx lines 490-515), ensuring the calculation of the next sort rules remains pure inside the state updater while the side effect of calling onSort happens separately.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/www/public/registry/data-table.json`:
- Line 11: The virtual window computation uses flowData (which excludes pinned
rows) to calculate visible row ranges, but the expanded-height compensation
logic checks row indices against rowIdIndex (which maps to sortedData including
all rows), and the aria-rowindex for virtual rendering doesn't account for
pinned rows. Fix this by: (1) adjusting the windowSlice useMemo to track which
rows are pinned when compensating for expanded heights, ensuring the index
checks work against the actual visible data rather than sortedData; (2) updating
the aria-rowindex calculation in the renderDataRow function to add the count of
pinned rows to account for their presence in the DOM; and (3) verifying that the
expanded height tracking and spacer math correctly handle the offset between
flowData indices and the complete row order when pinnedSet.size > 0.
In `@apps/www/tests/data-table.test.tsx`:
- Around line 379-381: The assertion on line 380 that checks for the absence of
"Person 0 " using includes() is fragile because it depends on exact whitespace
formatting in the concatenated cell text. Replace this string-matching approach
with a more semantic assertion that checks for the actual absence of row 0
content using word boundaries or by directly looking up specific cell values by
row identifier or token matching. This ensures the test will reliably fail when
row 0 is actually rendered, regardless of how the text formatting changes.
In `@docs/superpowers/specs/2026-06-09-data-table-v2-design.md`:
- Around line 55-81: The specification document has three alignment issues with
the implemented DataTableProps: First, add the missing sortable?: boolean
property to the feature flags section (after line 55-76) since the component
exposes it. Second, fix the invalid TypeScript shorthand on line 80 by replacing
the shorthand syntax (headerTextColor?, bodyTextColor?, headerBackground?,
bodyBackground?: string) with explicit per-field declarations using proper ?:
string type annotations for each property individually. Third, update the
forced-virtual conflicts documentation (lines 110-112) to also mention that
paginated conflicts with virtualization, not just rowSpan and groupBy.
In `@registry/data-table/component.tsx`:
- Around line 1171-1179: The sort priority indicator is hidden from assistive
technology by the aria-hidden wrapper, making it inaccessible to screen reader
users in multi-sort scenarios. Remove the aria-hidden attribute from the span
wrapper in both files and provide accessible context for the priority number. In
registry/data-table/component.tsx at lines 1171-1179, replace the aria-hidden
wrapper with an accessible solution that exposes the sortPriority value through
an aria-label (such as "sort priority 2") or an sr-only class element containing
descriptive priority text. Mirror the identical change in
apps/www/components/ui/data-table.tsx at lines 1171-1179 to ensure consistency
across both implementations. The directional indicator (↑/↓) may remain visual,
but the priority number must be announced to assistive technology.
- Around line 58-102: The DataTableProps interface is too restrictive and only
exposes className, preventing consumers from passing standard HTML attributes
like id, style, role, aria-*, and data-* to the root wrapper. Extend
DataTableProps to include relevant HTML div properties (such as
React.HTMLAttributes<HTMLDivElement>) while omitting any conflicting event
handlers per coding guidelines, then spread the resulting props onto the root
div element in the component. This pattern should be applied consistently across
all affected locations in the file.
- Around line 755-762: The rowSpanPlans computation is based on visibleRows but
rendering uses group-local indices when groupBy is active, causing row spans to
apply across unrelated rows in different groups. In
registry/data-table/component.tsx#L755-L762 (anchor), modify the useMemo
dependency and computation to use groupedRows instead of visibleRows when
grouping is active, ensuring span plans align with the actual rendered row
order. In registry/data-table/component.tsx#L1272-L1273 (sibling), remove the
group-local index being passed into renderDataRow when accessing the
rowSpanPlans, using the correct global index instead. Apply identical fixes to
the mirrored code in apps/www/components/ui/data-table.tsx at lines 755-762 and
1272-1273 respectively.
- Around line 551-560: The toggleSelectAll callback in both files replaces the
entire selection set, losing selections outside the current filtered set when
searching is active. Modify the toggleSelectAll callback logic in both
registry/data-table/component.tsx (lines 551-560) and
apps/www/components/ui/data-table.tsx (lines 551-560) to preserve existing
selections by deriving the next selection from the current selectedSet and only
adding or removing the filteredIds accordingly. When allFilteredSelected is
true, create a new Set from selectedSet and delete the filteredIds from it; when
false, create a new Set from selectedSet and add the filteredIds to it. Pass
this merged/diffed selection to updateSelection instead of replacing the entire
set.
- Around line 521-525: The rowIdIndex useMemo is currently built from
sortedData, but the virtual window is computed against flowData, causing
coordinate system misalignment when rows are pinned. In
registry/data-table/component.tsx at lines 521-525, change the rowIdIndex to
build from flowData instead of sortedData (or create a separate flowRowIdIndex).
Then in the same file at lines 691-696 where expanded-row spacer adjustment
occurs, update the lookup logic to use the flow-based index map and skip any IDs
that are missing from the map, as those represent pinned rows that should not
adjust virtual spacers. Mirror these exact same changes in
apps/www/components/ui/data-table.tsx at lines 521-525 and 691-696.
In `@registry/data-table/demo.tsx`:
- Around line 52-53: The issue is that the Date object created at lines 52-53
uses local time construction, which when formatted with toISOString() at lines
85-86 will shift to UTC and potentially display the wrong day depending on the
user's timezone. To fix this, change the Date constructor at lines 52-53 from
the local-time Date constructor (new Date(2026, i % 12, (i % 27) + 1)) to use
Date.UTC() to create the date in UTC coordinates, ensuring that when
toISOString() is applied at lines 85-86 for formatting, the displayed date
matches the intended calendar date without timezone shifting.
---
Nitpick comments:
In `@registry/data-table/component.tsx`:
- Around line 490-515: The toggleSort function in both
registry/data-table/component.tsx and apps/www/components/ui/data-table.tsx
calls onSort?.(next) inside the setSortRules state updater callback, which
violates the requirement that state updaters must be pure functions. This can
cause duplicate onSort invocations when React's Strict Mode calls the updater
multiple times. Move the onSort?.(next) invocation outside of the setSortRules
callback and either place it in a useEffect hook that depends on the sort rules,
or invoke it directly in the toggleSort event handler after the state update.
Make this change at both anchor site (registry/data-table/component.tsx lines
490-515) and sibling site (apps/www/components/ui/data-table.tsx lines 490-515),
ensuring the calculation of the next sort rules remains pure inside the state
updater while the side effect of calling onSort happens separately.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9cd5b703-e190-4b8d-8829-f21d05de1521
📒 Files selected for processing (20)
apps/www/components/ui/data-table.tsxapps/www/config/components.tsapps/www/config/demos.tsxapps/www/config/docs-scenarios.tsapps/www/public/r/data-table.jsonapps/www/public/r/registry.jsonapps/www/public/registry.jsonapps/www/public/registry/changelogs.jsonapps/www/public/registry/data-table.jsonapps/www/public/registry/dot-grid-background.jsonapps/www/public/registry/particle-field.jsonapps/www/public/registry/shooting-stars-grid.jsonapps/www/tests/data-table.test.tsxdocs/superpowers/specs/2026-06-09-data-table-v2-design.mdregistry.jsonregistry/components/data-table.jsonregistry/data-table/component.tsxregistry/data-table/demo.tsxregistry/demos/demo-key-order.jsonregistry/demos/shared.tsx
💤 Files with no reviewable changes (1)
- registry/demos/shared.tsx
| { | ||
| "path": "data-table/component.tsx", | ||
| "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport React, {\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\n/** Fallback width when column widths are not measured yet. */\nconst STICKY_COL_WIDTH_PX = 112;\n\nconst PAGE_SIZE_FALLBACK = 8;\n\nfunction normalizePageSize(input: unknown, fallback: number): number {\n const n = Math.floor(Number(input));\n if (!Number.isFinite(n) || n < 1) return fallback;\n return n;\n}\n\nfunction normalizeFreezeCount(input: unknown): number {\n const n = Math.floor(Number(input));\n if (!Number.isFinite(n) || n < 0) return 0;\n return n;\n}\n\nfunction defaultGetRowKey(\n row: Record<string, React.ReactNode>,\n index: number\n): React.Key {\n const id = row[\"id\"];\n const key = row[\"key\"];\n if (typeof id === \"string\" || typeof id === \"number\") return String(id);\n if (typeof key === \"string\" || typeof key === \"number\") return String(key);\n return `__row-${index}`;\n}\n\n/** Compact page list: first, window around current, last; ellipses when gaps exist. */\nfunction getPaginationItems(\n currentPage: number,\n totalPages: number\n): Array<number | \"ellipsis\"> {\n if (totalPages <= 7) {\n return Array.from({ length: totalPages }, (_, i) => i + 1);\n }\n const delta = 1;\n const items: Array<number | \"ellipsis\"> = [1];\n if (currentPage - delta > 2) items.push(\"ellipsis\");\n for (\n let p = Math.max(2, currentPage - delta);\n p <= Math.min(totalPages - 1, currentPage + delta);\n p++\n ) {\n items.push(p);\n }\n if (currentPage + delta < totalPages - 1) items.push(\"ellipsis\");\n if (totalPages > 1) items.push(totalPages);\n return items;\n}\n\nexport interface DataTableColumn {\n key: string;\n label: string;\n sortKey?: string;\n}\n\nexport interface DataTableOwnProps {\n columns: DataTableColumn[];\n data: Record<string, React.ReactNode>[];\n freezeColumns?: \"none\" | \"left\" | \"right\" | \"both\";\n /**\n * @deprecated Use `freezeLeftCount` and/or `freezeRightCount` instead.\n * Legacy fallback count when side-specific freeze counts are not provided.\n */\n freezeCount?: number;\n /**\n * How many columns to freeze on the **left** when `freezeColumns` is `\"left\"` or `\"both\"`.\n * Ignored when `freezeColumns` is `\"right\"` or `\"none\"`.\n */\n freezeLeftCount?: number;\n /**\n * How many columns to freeze on the **right** when `freezeColumns` is `\"right\"` or `\"both\"`.\n * Ignored when `freezeColumns` is `\"left\"` or `\"none\"`.\n */\n freezeRightCount?: number;\n paginated?: boolean;\n pageSize?: number;\n pageSizeOptions?: number[];\n initialPage?: number;\n onPageChange?: (page: number, pageSize: number) => void;\n /** Content for the previous-page control; defaults to a left chevron icon. */\n paginationPreviousLabel?: React.ReactNode;\n /** Content for the next-page control; defaults to a right chevron icon. */\n paginationNextLabel?: React.ReactNode;\n /**\n * Stable key for each row (sorting, pagination). Defaults to `row.id` / `row.key` when string/number, else a generated key.\n */\n getRowKey?: (row: Record<string, React.ReactNode>, index: number) => React.Key;\n headerTextColor?: string;\n bodyTextColor?: string;\n headerBackground?: string;\n bodyBackground?: string;\n border?: boolean;\n sortable?: boolean;\n onSort?: (key: string, direction: \"asc\" | \"desc\") => void;\n className?: string;\n theme?: \"light\" | \"dark\";\n}\n\nexport type DataTableProps = DataTableOwnProps &\n Omit<React.HTMLAttributes<HTMLDivElement>, keyof DataTableOwnProps | \"children\">;\n\nconst defaultHeaderTextColor = (theme: \"light\" | \"dark\") =>\n theme === \"dark\" ? \"text-neutral-200\" : \"text-neutral-900\";\nconst defaultBodyTextColor = (theme: \"light\" | \"dark\") =>\n theme === \"dark\" ? \"text-neutral-300\" : \"text-neutral-700\";\nconst defaultHeaderBackground = (theme: \"light\" | \"dark\") =>\n theme === \"dark\" ? \"bg-neutral-800\" : \"bg-neutral-100\";\nconst defaultBodyBackground = (theme: \"light\" | \"dark\") =>\n theme === \"dark\" ? \"bg-neutral-950\" : \"bg-white\";\n\nexport function DataTable({\n columns,\n data,\n freezeColumns = \"none\",\n freezeCount = 1,\n freezeLeftCount: freezeLeftCountProp,\n freezeRightCount: freezeRightCountProp,\n paginated = false,\n pageSize = PAGE_SIZE_FALLBACK,\n pageSizeOptions,\n initialPage = 1,\n onPageChange,\n paginationPreviousLabel = <ChevronLeft className=\"size-4\" aria-hidden />,\n paginationNextLabel = <ChevronRight className=\"size-4\" aria-hidden />,\n getRowKey,\n headerTextColor,\n bodyTextColor,\n headerBackground,\n bodyBackground,\n border = false,\n sortable = false,\n onSort,\n className,\n theme = \"dark\",\n ...rest\n}: DataTableProps) {\n const pageSizeSelectId = useId();\n const tableRef = useRef<HTMLTableElement>(null);\n const [colWidths, setColWidths] = useState<number[]>([]);\n\n const normalizedInitialPageSize = useMemo(\n () => normalizePageSize(pageSize, PAGE_SIZE_FALLBACK),\n [pageSize]\n );\n\n const [sortKey, setSortKey] = useState<string | null>(null);\n const [sortDirection, setSortDirection] = useState<\"asc\" | \"desc\">(\"asc\");\n const [currentPage, setCurrentPage] = useState(initialPage);\n const [currentPageSize, setCurrentPageSize] = useState(normalizedInitialPageSize);\n\n useEffect(() => {\n setCurrentPageSize(normalizedInitialPageSize);\n }, [normalizedInitialPageSize]);\n\n const effectivePageSize = normalizePageSize(currentPageSize, PAGE_SIZE_FALLBACK);\n\n useEffect(() => {\n if (!paginated) return;\n if (!pageSizeOptions?.length) return;\n if (!pageSizeOptions.includes(effectivePageSize)) {\n const next = normalizePageSize(pageSizeOptions[0], PAGE_SIZE_FALLBACK);\n setCurrentPageSize(next);\n setCurrentPage(1);\n onPageChange?.(1, next);\n }\n }, [paginated, pageSizeOptions, effectivePageSize, onPageChange]);\n\n const headerText = headerTextColor ?? defaultHeaderTextColor(theme);\n const bodyText = bodyTextColor ?? defaultBodyTextColor(theme);\n const headerBg = headerBackground ?? defaultHeaderBackground(theme);\n const bodyBg = bodyBackground ?? defaultBodyBackground(theme);\n\n const borderColor =\n theme === \"dark\" ? \"border-neutral-700\" : \"border-neutral-200\";\n const borderClass = border ? cn(\"border\", borderColor) : \"\";\n const cellBorderClass = border\n ? cn(\"border-b border-r last:border-r-0\", borderColor)\n : \"\";\n const headerCellBorderClass = border\n ? cn(\"border-b border-r last:border-r-0\", borderColor)\n : \"\";\n const rowSepBorderClass = border ? cn(\"border-b\", borderColor) : \"\";\n\n const paginationBtnBase =\n theme === \"dark\"\n ? \"border-neutral-700 hover:bg-neutral-800\"\n : \"border-neutral-300 hover:bg-neutral-100\";\n const paginationBtnActive =\n theme === \"dark\"\n ? \"bg-neutral-100 text-neutral-900 border-neutral-300\"\n : \"bg-neutral-900 text-white border-neutral-700\";\n const paginationBtnDisabled = \"cursor-not-allowed opacity-40 border-neutral-700\";\n const selectBorder =\n theme === \"dark\" ? \"border-neutral-700\" : \"border-neutral-300\";\n\n const handleSort = (key: string) => {\n if (!sortable) return;\n const nextDir =\n sortKey === key && sortDirection === \"asc\" ? \"desc\" : \"asc\";\n setSortKey(key);\n setSortDirection(nextDir);\n onSort?.(key, nextDir);\n };\n\n const sortedData = useMemo(() => {\n if (!sortKey) return data;\n return [...data].sort((a, b) => {\n const va = a[sortKey];\n const vb = b[sortKey];\n const aStr = typeof va === \"object\" ? String(va) : String(va ?? \"\");\n const bStr = typeof vb === \"object\" ? String(vb) : String(vb ?? \"\");\n const cmp = aStr.localeCompare(bStr, undefined, { numeric: true });\n return sortDirection === \"asc\" ? cmp : -cmp;\n });\n }, [data, sortKey, sortDirection]);\n\n const totalItems = sortedData.length;\n const totalPages = Math.max(\n 1,\n Math.ceil(\n totalItems / (paginated ? effectivePageSize : totalItems || 1)\n )\n );\n\n const safePage = Math.min(Math.max(currentPage, 1), totalPages);\n\n const n = columns.length;\n const isLeftFreeze = freezeColumns === \"left\" || freezeColumns === \"both\";\n const isRightFreeze = freezeColumns === \"right\" || freezeColumns === \"both\";\n const leftCount = normalizeFreezeCount(freezeLeftCountProp ?? freezeCount);\n const rightCount = normalizeFreezeCount(freezeRightCountProp ?? freezeCount);\n const leftFreezeCount = isLeftFreeze ? Math.min(leftCount, n) : 0;\n let rightFreezeCount = isRightFreeze ? Math.min(rightCount, n) : 0;\n if (freezeColumns === \"both\" && leftFreezeCount + rightFreezeCount > n) {\n rightFreezeCount = Math.max(0, n - leftFreezeCount);\n }\n\n const needsStickyMeasure = isLeftFreeze || isRightFreeze;\n\n const measureColWidths = useCallback(() => {\n if (!needsStickyMeasure) return;\n const table = tableRef.current;\n if (!table) return;\n const firstRow = table.querySelector(\"thead tr\");\n if (!firstRow) return;\n const ths = firstRow.querySelectorAll(\"th\");\n if (ths.length !== n) return;\n const w: number[] = [];\n ths.forEach((th) => w.push(th.getBoundingClientRect().width));\n if (w.length && w.every((x) => Number.isFinite(x) && x > 0)) {\n setColWidths(w);\n }\n }, [needsStickyMeasure, n]);\n\n useLayoutEffect(() => {\n measureColWidths();\n }, [\n measureColWidths,\n sortedData,\n columns,\n leftFreezeCount,\n rightFreezeCount,\n safePage,\n effectivePageSize,\n ]);\n\n useEffect(() => {\n if (!needsStickyMeasure) return;\n const table = tableRef.current;\n if (!table) return;\n const ro = new ResizeObserver(() => measureColWidths());\n ro.observe(table);\n window.addEventListener(\"resize\", measureColWidths);\n return () => {\n ro.disconnect();\n window.removeEventListener(\"resize\", measureColWidths);\n };\n }, [\n needsStickyMeasure,\n measureColWidths,\n safePage,\n effectivePageSize,\n ]);\n\n const stickyOffsets = useMemo(() => {\n const widths: number[] = [];\n for (let i = 0; i < n; i++) {\n const w = colWidths[i];\n widths.push(\n w !== undefined && Number.isFinite(w) && w > 0\n ? w\n : STICKY_COL_WIDTH_PX\n );\n }\n const leftOffsets: number[] = new Array(n);\n leftOffsets[0] = 0;\n for (let i = 1; i < n; i++) {\n leftOffsets[i] = leftOffsets[i - 1] + widths[i - 1];\n }\n const rightOffsets: number[] = new Array(n);\n let acc = 0;\n for (let i = n - 1; i >= 0; i--) {\n rightOffsets[i] = acc;\n acc += widths[i];\n }\n return { leftOffsets, rightOffsets, widths };\n }, [colWidths, n]);\n\n // Keep pagination state consistent if `data` length (or pageSize) changes.\n useEffect(() => {\n if (!paginated) return;\n\n setCurrentPage((prev) => {\n const clamped = Math.min(Math.max(prev, 1), totalPages);\n if (clamped !== prev) {\n onPageChange?.(clamped, effectivePageSize);\n return clamped;\n }\n return prev;\n });\n }, [paginated, totalPages, effectivePageSize, onPageChange]);\n\n const pageStartIndex = paginated ? (safePage - 1) * effectivePageSize : 0;\n const pageEndIndex = paginated\n ? pageStartIndex + effectivePageSize\n : totalItems;\n\n const visibleData = paginated\n ? sortedData.slice(pageStartIndex, pageEndIndex)\n : sortedData;\n\n const pageItems = useMemo(\n () => getPaginationItems(safePage, totalPages),\n [safePage, totalPages]\n );\n\n return (\n <div\n {...rest}\n className={cn(\"w-full rounded-lg\", className)}\n >\n <div className=\"w-full overflow-x-auto\">\n <table\n ref={tableRef}\n className={cn(\"w-full min-w-max text-sm text-left\", borderClass)}\n >\n <thead>\n <tr className={cn(headerBg)}>\n {columns.map((col, colIndex) => {\n const isStickyLeft =\n isLeftFreeze && colIndex < leftFreezeCount;\n const isStickyRight =\n isRightFreeze && colIndex >= n - rightFreezeCount;\n const stickyLeft = isStickyLeft\n ? stickyOffsets.leftOffsets[colIndex]\n : undefined;\n const stickyRight = isStickyRight\n ? stickyOffsets.rightOffsets[colIndex]\n : undefined;\n const canSort = sortable && col.sortKey != null;\n const ariaSort =\n canSort && sortKey === col.sortKey\n ? sortDirection === \"asc\"\n ? \"ascending\"\n : \"descending\"\n : undefined;\n const cw = stickyOffsets.widths[colIndex];\n\n return (\n <th\n key={col.key}\n scope=\"col\"\n aria-sort={ariaSort}\n className={cn(\n \"px-4 py-3 font-medium whitespace-nowrap\",\n headerText,\n headerBg,\n headerCellBorderClass\n )}\n style={{\n ...(isStickyLeft && {\n position: \"sticky\",\n left: stickyLeft,\n zIndex: 10,\n width: cw,\n minWidth: cw,\n boxShadow: stickyLeft\n ? \"4px 0 6px -2px rgba(0,0,0,0.1)\"\n : undefined,\n }),\n ...(isStickyRight && {\n position: \"sticky\",\n right: stickyRight,\n zIndex: 10,\n width: cw,\n minWidth: cw,\n boxShadow:\n stickyRight !== undefined\n ? \"-4px 0 6px -2px rgba(0,0,0,0.1)\"\n : undefined,\n }),\n }}\n >\n {canSort ? (\n <button\n type=\"button\"\n aria-label={`Sort by ${col.label} ${ariaSort ?? \"\"}`.trim()}\n onClick={() =>\n col.sortKey && handleSort(col.sortKey)\n }\n className={cn(\n \"inline-flex w-full max-w-full items-center gap-1 rounded-sm bg-transparent p-0 text-left font-inherit\",\n headerText,\n \"cursor-pointer select-none hover:opacity-80\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-offset-2\",\n theme === \"dark\"\n ? \"focus-visible:ring-neutral-600 focus-visible:ring-offset-neutral-950\"\n : \"focus-visible:ring-neutral-400 focus-visible:ring-offset-white\"\n )}\n >\n {col.label}\n {sortKey === col.sortKey && (\n <span className=\"ml-1\" aria-hidden>\n {sortDirection === \"asc\" ? \" ↑\" : \" ↓\"}\n </span>\n )}\n </button>\n ) : (\n col.label\n )}\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody>\n {visibleData.map((row, rowIndex) => {\n const globalRowIndex = paginated\n ? pageStartIndex + rowIndex\n : rowIndex;\n const rowKey =\n getRowKey?.(row, globalRowIndex) ??\n defaultGetRowKey(row, globalRowIndex);\n\n return (\n <tr\n key={rowKey}\n className={cn(\n bodyBg,\n rowIndex < visibleData.length - 1 && border && rowSepBorderClass\n )}\n >\n {columns.map((col, colIndex) => {\n const isStickyLeft =\n isLeftFreeze && colIndex < leftFreezeCount;\n const isStickyRight =\n isRightFreeze && colIndex >= n - rightFreezeCount;\n const stickyLeft = isStickyLeft\n ? stickyOffsets.leftOffsets[colIndex]\n : undefined;\n const stickyRight = isStickyRight\n ? stickyOffsets.rightOffsets[colIndex]\n : undefined;\n const cw = stickyOffsets.widths[colIndex];\n\n return (\n <td\n key={col.key}\n className={cn(\n \"px-4 py-3\",\n bodyText,\n bodyBg,\n cellBorderClass\n )}\n style={{\n ...(isStickyLeft && {\n position: \"sticky\",\n left: stickyLeft,\n zIndex: 5,\n width: cw,\n minWidth: cw,\n boxShadow: stickyLeft\n ? \"4px 0 6px -2px rgba(0,0,0,0.08)\"\n : undefined,\n }),\n ...(isStickyRight && {\n position: \"sticky\",\n right: stickyRight,\n zIndex: 5,\n width: cw,\n minWidth: cw,\n boxShadow:\n stickyRight !== undefined\n ? \"-4px 0 6px -2px rgba(0,0,0,0.08)\"\n : undefined,\n }),\n }}\n >\n {row[col.key]}\n </td>\n );\n })}\n </tr>\n );\n })}\n </tbody>\n </table>\n </div>\n\n {paginated && totalItems > 0 && (\n <nav\n className={cn(\n \"mt-4 flex flex-col gap-3 items-center justify-between text-xs sm:text-sm sm:flex-row\",\n bodyText\n )}\n aria-label=\"Table pagination\"\n >\n <div className=\"text-xs sm:text-sm\">\n {`Showing ${pageStartIndex + 1}-${Math.min(\n pageEndIndex,\n totalItems\n )} of ${totalItems}`}\n </div>\n <div className=\"flex items-center gap-3\">\n {pageSizeOptions && pageSizeOptions.length > 0 && (\n <div className=\"flex items-center gap-1\">\n <label\n htmlFor={pageSizeSelectId}\n className=\"hidden sm:inline\"\n >\n Rows per page\n </label>\n <select\n id={pageSizeSelectId}\n className={cn(\n \"h-8 rounded-md border bg-transparent px-2 text-xs sm:text-sm outline-none\",\n selectBorder\n )}\n aria-label=\"Rows per page\"\n value={effectivePageSize}\n onChange={(e) => {\n const nextSize = normalizePageSize(\n e.target.value,\n effectivePageSize\n );\n setCurrentPageSize(nextSize);\n const nextPage = 1;\n setCurrentPage(nextPage);\n onPageChange?.(nextPage, nextSize);\n }}\n >\n {pageSizeOptions.map((size) => (\n <option key={size} value={size}>\n {size}\n </option>\n ))}\n </select>\n </div>\n )}\n\n <div className=\"flex items-center gap-1\">\n <button\n type=\"button\"\n aria-label=\"Previous page\"\n className={cn(\n \"inline-flex h-8 min-w-8 shrink-0 items-center justify-center px-2 rounded-md border text-xs sm:text-sm leading-none\",\n safePage === 1 ? paginationBtnDisabled : paginationBtnBase\n )}\n onClick={() => {\n if (safePage <= 1) return;\n const nextPage = safePage - 1;\n setCurrentPage(nextPage);\n onPageChange?.(nextPage, effectivePageSize);\n }}\n disabled={safePage === 1}\n >\n {paginationPreviousLabel}\n </button>\n {pageItems.map((item, itemIdx) =>\n item === \"ellipsis\" ? (\n <span\n key={`ellipsis-${itemIdx}`}\n className=\"inline-flex h-8 min-w-8 shrink-0 items-center justify-center px-1 text-xs sm:text-sm tabular-nums text-neutral-500\"\n aria-hidden\n >\n …\n </span>\n ) : (\n <button\n key={item}\n type=\"button\"\n className={cn(\n \"inline-flex h-8 min-w-8 shrink-0 items-center justify-center px-2 rounded-md border text-xs sm:text-sm leading-none tabular-nums\",\n item === safePage ? paginationBtnActive : paginationBtnBase\n )}\n aria-current={item === safePage ? \"page\" : undefined}\n onClick={() => {\n setCurrentPage(item);\n onPageChange?.(item, effectivePageSize);\n }}\n >\n {item}\n </button>\n )\n )}\n <button\n type=\"button\"\n aria-label=\"Next page\"\n className={cn(\n \"inline-flex h-8 min-w-8 shrink-0 items-center justify-center px-2 rounded-md border text-xs sm:text-sm leading-none\",\n safePage === totalPages\n ? paginationBtnDisabled\n : paginationBtnBase\n )}\n onClick={() => {\n if (safePage >= totalPages) return;\n const nextPage = safePage + 1;\n setCurrentPage(nextPage);\n onPageChange?.(nextPage, effectivePageSize);\n }}\n disabled={safePage === totalPages}\n >\n {paginationNextLabel}\n </button>\n </div>\n </div>\n </nav>\n )}\n </div>\n );\n}\n", | ||
| "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n ChevronDown,\n ChevronLeft,\n ChevronRight,\n Search,\n} from \"lucide-react\";\nimport React, {\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\n/* ------------------------------------------------------------------ */\n/* Types */\n/* ------------------------------------------------------------------ */\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface DataTableSortRule {\n id: string;\n direction: SortDirection;\n}\n\nexport type DataTablePreset = \"basic\" | \"advanced\" | \"enterprise\";\n\nexport interface DataTableColumn<T> {\n /** Unique column id; used for sorting, grouping, and freezing. */\n id: string;\n header: React.ReactNode;\n /** Raw value used for sorting, searching, and grouping. */\n accessor: (row: T) => unknown;\n /** Optional cell renderer; defaults to `String(accessor(row))`. */\n cell?: (row: T) => React.ReactNode;\n /** Comparator type; \"auto\" infers number/date/string from the data. */\n sortType?: \"string\" | \"number\" | \"date\" | \"auto\";\n /** Overrides the table-level `sortable` flag for this column. */\n sortable?: boolean;\n /** Include this column's values in global search. Default true. */\n searchable?: boolean;\n /** Fixed width in px; improves frozen-column offsets and windowing. */\n width?: number;\n align?: \"left\" | \"center\" | \"right\";\n /** Pin the column to an edge while horizontally scrolling. */\n freeze?: \"left\" | \"right\";\n /** Nested columns render a grouped, multi-level header. */\n columns?: DataTableColumn<T>[];\n /** Merge consecutive equal accessor values (standard engine only). */\n rowSpan?: boolean;\n}\n\nexport interface DataTableProps<T> {\n data: T[];\n columns: DataTableColumn<T>[];\n /** Stable row id — selection, expansion, pinning, and windowing keys. */\n getRowId: (row: T) => string;\n\n /** Bundles feature defaults; any explicit prop overrides the preset. */\n preset?: DataTablePreset;\n\n searchable?: boolean;\n paginated?: boolean;\n pageSize?: number;\n pageSizeOptions?: number[];\n sortable?: boolean;\n multiSort?: boolean;\n onSort?: (sort: DataTableSortRule[]) => void;\n selectable?: boolean;\n selectedIds?: string[];\n onSelectionChange?: (ids: string[]) => void;\n expandable?: boolean;\n renderExpanded?: (row: T) => React.ReactNode;\n expandedIds?: string[];\n onExpandedChange?: (ids: string[]) => void;\n /** Column id to group rows by — switches to the standard engine. */\n groupBy?: string;\n /** Force the engine; defaults to automatic selection. */\n virtualized?: boolean;\n /** Row count above which flat data is windowed. Default 100. */\n virtualizeThreshold?: number;\n /** Estimated row height in px used for windowing math. Default 44. */\n rowHeight?: number;\n /** Scroll container height; required for a useful virtualized view. */\n maxHeight?: number | string;\n stickyHeader?: boolean;\n /** Row ids kept visible directly below the header. */\n pinnedRows?: string[];\n\n theme?: \"light\" | \"dark\";\n border?: boolean;\n headerTextColor?: string;\n bodyTextColor?: string;\n headerBackground?: string;\n bodyBackground?: string;\n className?: string;\n}\n\n/* ------------------------------------------------------------------ */\n/* Pure helpers */\n/* ------------------------------------------------------------------ */\n\nconst DEFAULT_ROW_HEIGHT = 44;\nconst DEFAULT_VIRTUALIZE_THRESHOLD = 100;\nconst DEFAULT_OVERSCAN = 5;\nconst DEFAULT_VIRTUAL_MAX_HEIGHT = 480;\nconst FALLBACK_COL_WIDTH_PX = 112;\nconst LEADING_COL_WIDTH_PX = 44;\nconst PAGE_SIZE_FALLBACK = 10;\nconst SEARCH_DEBOUNCE_MS = 150;\n\nexport interface WindowSlice {\n start: number;\n end: number;\n padTop: number;\n padBottom: number;\n}\n\n/**\n * Visible-window slice for hand-rolled row virtualization. Spacer heights\n * (`padTop`/`padBottom`) always complement the window so the scrollbar\n * reflects the full row count.\n */\nexport function computeWindow({\n scrollTop,\n viewportHeight,\n rowHeight,\n rowCount,\n overscan = DEFAULT_OVERSCAN,\n}: {\n scrollTop: number;\n viewportHeight: number;\n rowHeight: number;\n rowCount: number;\n overscan?: number;\n}): WindowSlice {\n const safeRowHeight = Math.max(1, rowHeight);\n const visibleCount = Math.max(1, Math.ceil(viewportHeight / safeRowHeight));\n const maxFirst = Math.max(0, rowCount - visibleCount);\n const firstVisible = Math.min(\n Math.max(0, Math.floor(scrollTop / safeRowHeight)),\n maxFirst\n );\n const start = Math.max(0, firstVisible - overscan);\n const end = Math.min(rowCount, firstVisible + visibleCount + overscan);\n return {\n start,\n end,\n padTop: start * safeRowHeight,\n padBottom: Math.max(0, rowCount - end) * safeRowHeight,\n };\n}\n\ntype ResolvedSortType = \"string\" | \"number\" | \"date\";\n\nfunction inferSortType(value: unknown): ResolvedSortType {\n if (typeof value === \"number\") return \"number\";\n if (value instanceof Date) return \"date\";\n return \"string\";\n}\n\nfunction compareValues(\n a: unknown,\n b: unknown,\n type: ResolvedSortType\n): number {\n const aMissing = a == null || (typeof a === \"number\" && Number.isNaN(a));\n const bMissing = b == null || (typeof b === \"number\" && Number.isNaN(b));\n if (aMissing && bMissing) return 0;\n if (aMissing) return 1; // missing values sort last\n if (bMissing) return -1;\n if (type === \"number\") return Number(a) - Number(b);\n if (type === \"date\") {\n return new Date(a as Date).getTime() - new Date(b as Date).getTime();\n }\n return String(a).localeCompare(String(b), undefined, {\n numeric: true,\n sensitivity: \"base\",\n });\n}\n\nfunction normalizePageSize(input: unknown, fallback: number): number {\n const n = Math.floor(Number(input));\n if (!Number.isFinite(n) || n < 1) return fallback;\n return n;\n}\n\n/** Compact page list: first, window around current, last; ellipses when gaps exist. */\nfunction getPaginationItems(\n currentPage: number,\n totalPages: number\n): Array<number | \"ellipsis\"> {\n if (totalPages <= 7) {\n return Array.from({ length: totalPages }, (_, i) => i + 1);\n }\n const delta = 1;\n const items: Array<number | \"ellipsis\"> = [1];\n if (currentPage - delta > 2) items.push(\"ellipsis\");\n for (\n let p = Math.max(2, currentPage - delta);\n p <= Math.min(totalPages - 1, currentPage + delta);\n p++\n ) {\n items.push(p);\n }\n if (currentPage + delta < totalPages - 1) items.push(\"ellipsis\");\n if (totalPages > 1) items.push(totalPages);\n return items;\n}\n\ninterface HeaderCell<T> {\n column: DataTableColumn<T>;\n colSpan: number;\n rowSpan: number;\n leaf: boolean;\n /** Index of the first leaf column this cell spans. */\n leafIndex: number;\n}\n\nfunction getColumnDepth<T>(columns: DataTableColumn<T>[]): number {\n let depth = 1;\n for (const col of columns) {\n if (col.columns?.length) {\n depth = Math.max(depth, 1 + getColumnDepth(col.columns));\n }\n }\n return depth;\n}\n\n/** Leaf columns in visual order, inheriting `freeze` from group parents. */\nfunction flattenColumns<T>(\n columns: DataTableColumn<T>[],\n parentFreeze?: \"left\" | \"right\"\n): DataTableColumn<T>[] {\n const leaves: DataTableColumn<T>[] = [];\n for (const col of columns) {\n const freeze = col.freeze ?? parentFreeze;\n if (col.columns?.length) {\n leaves.push(...flattenColumns(col.columns, freeze));\n } else {\n leaves.push(freeze === col.freeze ? col : { ...col, freeze });\n }\n }\n return leaves;\n}\n\nfunction buildHeaderRows<T>(\n columns: DataTableColumn<T>[],\n depth: number\n): HeaderCell<T>[][] {\n const rows: HeaderCell<T>[][] = Array.from({ length: depth }, () => []);\n let leafCount = 0;\n\n function place(col: DataTableColumn<T>, level: number) {\n if (col.columns?.length) {\n const cell: HeaderCell<T> = {\n column: col,\n colSpan: 0,\n rowSpan: 1,\n leaf: false,\n leafIndex: leafCount,\n };\n rows[level].push(cell);\n for (const child of col.columns) place(child, level + 1);\n cell.colSpan = leafCount - cell.leafIndex;\n } else {\n rows[level].push({\n column: col,\n colSpan: 1,\n rowSpan: depth - level,\n leaf: true,\n leafIndex: leafCount,\n });\n leafCount += 1;\n }\n }\n\n for (const col of columns) place(col, 0);\n return rows;\n}\n\n/** rowSpan column merge plan: span count at the first row of a run, 0 inside it. */\nfunction computeRowSpans<T>(\n rows: T[],\n column: DataTableColumn<T>\n): number[] {\n const spans = new Array<number>(rows.length).fill(1);\n let runStart = 0;\n for (let i = 1; i <= rows.length; i++) {\n const prev = column.accessor(rows[i - 1]);\n const same = i < rows.length && Object.is(column.accessor(rows[i]), prev);\n if (!same) {\n spans[runStart] = i - runStart;\n for (let j = runStart + 1; j < i; j++) spans[j] = 0;\n runStart = i;\n }\n }\n return spans;\n}\n\nconst PRESET_DEFAULTS: Record<\n DataTablePreset,\n Partial<\n Pick<\n DataTableProps<unknown>,\n | \"searchable\"\n | \"paginated\"\n | \"sortable\"\n | \"multiSort\"\n | \"selectable\"\n | \"expandable\"\n | \"stickyHeader\"\n >\n >\n> = {\n basic: { searchable: true, paginated: true, sortable: true },\n advanced: {\n searchable: true,\n paginated: true,\n sortable: true,\n multiSort: true,\n selectable: true,\n expandable: true,\n stickyHeader: true,\n },\n enterprise: {\n searchable: true,\n sortable: true,\n multiSort: true,\n selectable: true,\n expandable: true,\n stickyHeader: true,\n },\n};\n\n/* ------------------------------------------------------------------ */\n/* Theming */\n/* ------------------------------------------------------------------ */\n\nconst defaultHeaderTextColor = (theme: \"light\" | \"dark\") =>\n theme === \"dark\" ? \"text-neutral-200\" : \"text-neutral-900\";\nconst defaultBodyTextColor = (theme: \"light\" | \"dark\") =>\n theme === \"dark\" ? \"text-neutral-300\" : \"text-neutral-700\";\nconst defaultHeaderBackground = (theme: \"light\" | \"dark\") =>\n theme === \"dark\" ? \"bg-neutral-800\" : \"bg-neutral-100\";\nconst defaultBodyBackground = (theme: \"light\" | \"dark\") =>\n theme === \"dark\" ? \"bg-neutral-950\" : \"bg-white\";\n\n/* ------------------------------------------------------------------ */\n/* Component */\n/* ------------------------------------------------------------------ */\n\nexport function DataTable<T>({\n data,\n columns,\n getRowId,\n preset,\n searchable,\n paginated,\n pageSize = PAGE_SIZE_FALLBACK,\n pageSizeOptions,\n sortable,\n multiSort,\n onSort,\n selectable,\n selectedIds,\n onSelectionChange,\n expandable,\n renderExpanded,\n expandedIds,\n onExpandedChange,\n groupBy,\n virtualized,\n virtualizeThreshold = DEFAULT_VIRTUALIZE_THRESHOLD,\n rowHeight = DEFAULT_ROW_HEIGHT,\n maxHeight,\n stickyHeader,\n pinnedRows,\n theme = \"dark\",\n border = false,\n headerTextColor,\n bodyTextColor,\n headerBackground,\n bodyBackground,\n className,\n}: DataTableProps<T>) {\n /* ----- feature resolution: explicit prop > preset > base default ---- */\n const presetDefaults = preset ? PRESET_DEFAULTS[preset] : {};\n const searchEnabled = searchable ?? presetDefaults.searchable ?? false;\n const paginatedEnabled = paginated ?? presetDefaults.paginated ?? false;\n const tableSortable = sortable ?? presetDefaults.sortable ?? false;\n const multiSortEnabled = multiSort ?? presetDefaults.multiSort ?? false;\n const selectableEnabled = selectable ?? presetDefaults.selectable ?? false;\n const expandEnabled =\n (expandable ?? presetDefaults.expandable ?? false) && !!renderExpanded;\n const stickyHeaderEnabled =\n stickyHeader ?? presetDefaults.stickyHeader ?? false;\n\n /* ----- columns ------------------------------------------------------ */\n const leafColumns = useMemo(() => flattenColumns(columns), [columns]);\n const headerDepth = useMemo(() => getColumnDepth(columns), [columns]);\n const headerRows = useMemo(\n () => buildHeaderRows(columns, headerDepth),\n [columns, headerDepth]\n );\n const anyRowSpan = leafColumns.some((c) => c.rowSpan);\n\n /* ----- engine decision ---------------------------------------------- */\n const wantsGrouping = !!groupBy || anyRowSpan;\n const isVirtual =\n virtualized ??\n (!paginatedEnabled && !wantsGrouping && data.length > virtualizeThreshold);\n const groupingActive = wantsGrouping && !(virtualized === true);\n const warnedRef = useRef(false);\n const conflictingForce =\n virtualized === true && (wantsGrouping || paginatedEnabled);\n useEffect(() => {\n if (\n process.env.NODE_ENV !== \"production\" &&\n conflictingForce &&\n !warnedRef.current\n ) {\n warnedRef.current = true;\n console.warn(\n \"DataTable: `virtualized` cannot be combined with `groupBy`/`rowSpan`/`paginated`; the unsupported feature is ignored.\"\n );\n }\n }, [conflictingForce]);\n const virtual = isVirtual && !paginatedEnabled;\n\n /* ----- search -------------------------------------------------------- */\n const [searchInput, setSearchInput] = useState(\"\");\n const [query, setQuery] = useState(\"\");\n useEffect(() => {\n const t = setTimeout(\n () => setQuery(searchInput.trim().toLowerCase()),\n SEARCH_DEBOUNCE_MS\n );\n return () => clearTimeout(t);\n }, [searchInput]);\n\n const searchedData = useMemo(() => {\n if (!searchEnabled || !query) return data;\n const searchCols = leafColumns.filter((c) => c.searchable !== false);\n return data.filter((row) =>\n searchCols.some((col) => {\n const value = col.accessor(row);\n if (value == null) return false;\n return String(value).toLowerCase().includes(query);\n })\n );\n }, [data, query, searchEnabled, leafColumns]);\n\n /* ----- sort ----------------------------------------------------------- */\n const [sortRules, setSortRules] = useState<DataTableSortRule[]>([]);\n\n const sortedData = useMemo(() => {\n if (!sortRules.length) return searchedData;\n const resolved = sortRules\n .map((rule) => {\n const col = leafColumns.find((c) => c.id === rule.id);\n if (!col) return null;\n let type: ResolvedSortType;\n if (col.sortType && col.sortType !== \"auto\") {\n type = col.sortType;\n } else {\n const sample = searchedData\n .map((row) => col.accessor(row))\n .find((v) => v != null);\n type = inferSortType(sample);\n }\n return { col, type, direction: rule.direction };\n })\n .filter((r): r is NonNullable<typeof r> => r !== null);\n if (!resolved.length) return searchedData;\n return [...searchedData].sort((a, b) => {\n for (const { col, type, direction } of resolved) {\n const cmp = compareValues(col.accessor(a), col.accessor(b), type);\n if (cmp !== 0) return direction === \"asc\" ? cmp : -cmp;\n }\n return 0;\n });\n }, [searchedData, sortRules, leafColumns]);\n\n const toggleSort = useCallback(\n (colId: string, shiftKey: boolean) => {\n setSortRules((prev) => {\n const existing = prev.find((r) => r.id === colId);\n let next: DataTableSortRule[];\n if (multiSortEnabled && shiftKey && prev.length > 0) {\n if (!existing) {\n next = [...prev, { id: colId, direction: \"asc\" }];\n } else if (existing.direction === \"asc\") {\n next = prev.map((r) =>\n r.id === colId ? { ...r, direction: \"desc\" as const } : r\n );\n } else {\n next = prev.filter((r) => r.id !== colId);\n }\n } else if (existing && prev.length === 1) {\n next =\n existing.direction === \"asc\"\n ? [{ id: colId, direction: \"desc\" }]\n : [];\n } else {\n next = [{ id: colId, direction: \"asc\" }];\n }\n onSort?.(next);\n return next;\n });\n },\n [multiSortEnabled, onSort]\n );\n\n /* ----- ids, selection, expansion -------------------------------------- */\n const rowIdIndex = useMemo(() => {\n const map = new Map<string, number>();\n sortedData.forEach((row, i) => map.set(getRowId(row), i));\n return map;\n }, [sortedData, getRowId]);\n\n const [internalSelected, setInternalSelected] = useState<Set<string>>(\n () => new Set()\n );\n const selectedSet = useMemo(\n () => (selectedIds ? new Set(selectedIds) : internalSelected),\n [selectedIds, internalSelected]\n );\n const updateSelection = useCallback(\n (next: Set<string>) => {\n if (selectedIds === undefined) setInternalSelected(next);\n onSelectionChange?.([...next]);\n },\n [selectedIds, onSelectionChange]\n );\n const toggleRowSelected = useCallback(\n (id: string) => {\n const next = new Set(selectedSet);\n if (next.has(id)) next.delete(id);\n else next.add(id);\n updateSelection(next);\n },\n [selectedSet, updateSelection]\n );\n\n const filteredIds = useMemo(\n () => sortedData.map((row) => getRowId(row)),\n [sortedData, getRowId]\n );\n const allFilteredSelected =\n filteredIds.length > 0 && filteredIds.every((id) => selectedSet.has(id));\n const someFilteredSelected = filteredIds.some((id) => selectedSet.has(id));\n const toggleSelectAll = useCallback(() => {\n updateSelection(allFilteredSelected ? new Set() : new Set(filteredIds));\n }, [allFilteredSelected, filteredIds, updateSelection]);\n\n const [internalExpanded, setInternalExpanded] = useState<Set<string>>(\n () => new Set()\n );\n const expandedSet = useMemo(\n () => (expandedIds ? new Set(expandedIds) : internalExpanded),\n [expandedIds, internalExpanded]\n );\n const toggleRowExpanded = useCallback(\n (id: string) => {\n const next = new Set(expandedSet);\n if (next.has(id)) next.delete(id);\n else next.add(id);\n if (expandedIds === undefined) setInternalExpanded(next);\n onExpandedChange?.([...next]);\n },\n [expandedSet, expandedIds, onExpandedChange]\n );\n\n /* ----- group collapse -------------------------------------------------- */\n const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(\n () => new Set()\n );\n const toggleGroup = useCallback((key: string) => {\n setCollapsedGroups((prev) => {\n const next = new Set(prev);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n });\n }, []);\n\n /* ----- pinned rows ------------------------------------------------------ */\n const pinnedSet = useMemo(() => new Set(pinnedRows ?? []), [pinnedRows]);\n const pinnedData = useMemo(\n () =>\n pinnedSet.size\n ? sortedData.filter((row) => pinnedSet.has(getRowId(row)))\n : [],\n [sortedData, pinnedSet, getRowId]\n );\n const flowData = useMemo(\n () =>\n pinnedSet.size\n ? sortedData.filter((row) => !pinnedSet.has(getRowId(row)))\n : sortedData,\n [sortedData, pinnedSet, getRowId]\n );\n\n /* ----- pagination ------------------------------------------------------- */\n const normalizedPageSize = normalizePageSize(pageSize, PAGE_SIZE_FALLBACK);\n const [currentPage, setCurrentPage] = useState(1);\n const [currentPageSize, setCurrentPageSize] = useState(normalizedPageSize);\n useEffect(() => {\n setCurrentPageSize(normalizedPageSize);\n }, [normalizedPageSize]);\n const effectivePageSize = normalizePageSize(\n currentPageSize,\n PAGE_SIZE_FALLBACK\n );\n const totalItems = flowData.length;\n const totalPages = Math.max(\n 1,\n Math.ceil(totalItems / (paginatedEnabled ? effectivePageSize : totalItems || 1))\n );\n const safePage = Math.min(Math.max(currentPage, 1), totalPages);\n useEffect(() => {\n if (!paginatedEnabled) return;\n setCurrentPage((prev) => Math.min(Math.max(prev, 1), totalPages));\n }, [paginatedEnabled, totalPages]);\n\n const pageStartIndex = paginatedEnabled\n ? (safePage - 1) * effectivePageSize\n : 0;\n const pageEndIndex = paginatedEnabled\n ? Math.min(pageStartIndex + effectivePageSize, totalItems)\n : totalItems;\n\n /* ----- virtualization ----------------------------------------------------- */\n const viewportRef = useRef<HTMLDivElement>(null);\n const [scrollTop, setScrollTop] = useState(0);\n const [measuredViewport, setMeasuredViewport] = useState(0);\n const viewportHeight =\n typeof maxHeight === \"number\"\n ? maxHeight\n : measuredViewport || DEFAULT_VIRTUAL_MAX_HEIGHT;\n\n useLayoutEffect(() => {\n if (!virtual || typeof maxHeight === \"number\") return;\n const el = viewportRef.current;\n if (!el) return;\n const measure = () => {\n const h = el.clientHeight;\n if (h > 0) setMeasuredViewport(h);\n };\n measure();\n const ro = new ResizeObserver(measure);\n ro.observe(el);\n return () => ro.disconnect();\n }, [virtual, maxHeight]);\n\n const [expandedHeights, setExpandedHeights] = useState<\n Record<string, number>\n >({});\n const measureExpandedRow = useCallback(\n (id: string) => (el: HTMLTableRowElement | null) => {\n if (!el) return;\n const h = el.offsetHeight;\n if (h > 0) {\n setExpandedHeights((prev) =>\n Math.abs((prev[id] ?? 0) - h) > 1 ? { ...prev, [id]: h } : prev\n );\n }\n },\n []\n );\n\n const windowSlice = useMemo(() => {\n if (!virtual) return null;\n const base = computeWindow({\n scrollTop,\n viewportHeight,\n rowHeight,\n rowCount: flowData.length,\n });\n if (!expandedSet.size) return base;\n // Expanded panels add real height; shift the spacers so the scrollbar\n // still reflects the full content size.\n let extraAbove = 0;\n let extraBelow = 0;\n for (const id of expandedSet) {\n const index = rowIdIndex.get(id);\n if (index === undefined) continue;\n const extra = expandedHeights[id] ?? 0;\n if (index < base.start) extraAbove += extra;\n else if (index >= base.end) extraBelow += extra;\n }\n return {\n ...base,\n padTop: base.padTop + extraAbove,\n padBottom: base.padBottom + extraBelow,\n };\n }, [\n virtual,\n scrollTop,\n viewportHeight,\n rowHeight,\n flowData.length,\n expandedSet,\n expandedHeights,\n rowIdIndex,\n ]);\n\n const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {\n setScrollTop(e.currentTarget.scrollTop);\n }, []);\n\n /* ----- visible rows -------------------------------------------------------- */\n const visibleRows = useMemo(() => {\n if (virtual && windowSlice) {\n return flowData.slice(windowSlice.start, windowSlice.end);\n }\n if (paginatedEnabled) return flowData.slice(pageStartIndex, pageEndIndex);\n return flowData;\n }, [\n virtual,\n windowSlice,\n paginatedEnabled,\n flowData,\n pageStartIndex,\n pageEndIndex,\n ]);\n\n /* ----- grouping -------------------------------------------------------------- */\n const groupColumn = groupingActive && groupBy\n ? leafColumns.find((c) => c.id === groupBy)\n : undefined;\n const groupedRows = useMemo(() => {\n if (!groupColumn) return null;\n const groups: Array<{ key: string; rows: T[] }> = [];\n const byKey = new Map<string, T[]>();\n for (const row of visibleRows) {\n const key = String(groupColumn.accessor(row) ?? \"\");\n let bucket = byKey.get(key);\n if (!bucket) {\n bucket = [];\n byKey.set(key, bucket);\n groups.push({ key, rows: bucket });\n }\n bucket.push(row);\n }\n return groups;\n }, [groupColumn, visibleRows]);\n\n const rowSpanPlans = useMemo(() => {\n if (virtual || !anyRowSpan) return null;\n const plans = new Map<string, number[]>();\n for (const col of leafColumns) {\n if (col.rowSpan) plans.set(col.id, computeRowSpans(visibleRows, col));\n }\n return plans;\n }, [virtual, anyRowSpan, leafColumns, visibleRows]);\n\n /* ----- frozen columns ---------------------------------------------------------- */\n const tableRef = useRef<HTMLTableElement>(null);\n const theadRef = useRef<HTMLTableSectionElement>(null);\n const [measuredWidths, setMeasuredWidths] = useState<number[]>([]);\n const [headerHeight, setHeaderHeight] = useState(0);\n const anyFreeze = leafColumns.some((c) => c.freeze);\n const leadingCount = (selectableEnabled ? 1 : 0) + (expandEnabled ? 1 : 0);\n const fullColSpan = leadingCount + leafColumns.length;\n\n const measureWidths = useCallback(() => {\n if (!anyFreeze) return;\n const table = tableRef.current;\n if (!table) return;\n const firstRow = table.querySelector(\"tbody tr[data-row]\");\n if (!firstRow) return;\n const cells = firstRow.querySelectorAll(\"td\");\n if (cells.length !== fullColSpan) return; // rowSpan merges break alignment\n const widths: number[] = [];\n cells.forEach((cell, i) => {\n if (i >= leadingCount) widths.push(cell.getBoundingClientRect().width);\n });\n if (widths.length && widths.every((w) => Number.isFinite(w) && w > 0)) {\n setMeasuredWidths(widths);\n }\n }, [anyFreeze, fullColSpan, leadingCount]);\n\n useLayoutEffect(() => {\n measureWidths();\n const thead = theadRef.current;\n if (thead && (pinnedData.length || stickyHeaderEnabled)) {\n const h = thead.getBoundingClientRect().height;\n if (h > 0) setHeaderHeight((prev) => (Math.abs(prev - h) > 1 ? h : prev));\n }\n }, [measureWidths, visibleRows, pinnedData.length, stickyHeaderEnabled]);\n\n useEffect(() => {\n if (!anyFreeze) return;\n const table = tableRef.current;\n if (!table) return;\n const ro = new ResizeObserver(() => measureWidths());\n ro.observe(table);\n window.addEventListener(\"resize\", measureWidths);\n return () => {\n ro.disconnect();\n window.removeEventListener(\"resize\", measureWidths);\n };\n }, [anyFreeze, measureWidths]);\n\n const freezeOffsets = useMemo(() => {\n const widths = leafColumns.map(\n (col, i) =>\n col.width ??\n (measuredWidths[i] && measuredWidths[i] > 0\n ? measuredWidths[i]\n : FALLBACK_COL_WIDTH_PX)\n );\n const left = new Map<number, number>();\n const right = new Map<number, number>();\n let leftAcc = leadingCount * LEADING_COL_WIDTH_PX;\n leafColumns.forEach((col, i) => {\n if (col.freeze === \"left\") {\n left.set(i, leftAcc);\n leftAcc += widths[i];\n }\n });\n let rightAcc = 0;\n for (let i = leafColumns.length - 1; i >= 0; i--) {\n if (leafColumns[i].freeze === \"right\") {\n right.set(i, rightAcc);\n rightAcc += widths[i];\n }\n }\n return { left, right, widths };\n }, [leafColumns, measuredWidths, leadingCount]);\n const anyFreezeLeft = freezeOffsets.left.size > 0;\n\n /* ----- theming -------------------------------------------------------------------- */\n const headerText = headerTextColor ?? defaultHeaderTextColor(theme);\n const bodyText = bodyTextColor ?? defaultBodyTextColor(theme);\n const headerBg = headerBackground ?? defaultHeaderBackground(theme);\n const bodyBg = bodyBackground ?? defaultBodyBackground(theme);\n const borderColor =\n theme === \"dark\" ? \"border-neutral-700\" : \"border-neutral-200\";\n const borderClass = border ? cn(\"border\", borderColor) : \"\";\n const cellBorderClass = border\n ? cn(\"border-b border-r last:border-r-0\", borderColor)\n : \"\";\n const paginationBtnBase =\n theme === \"dark\"\n ? \"border-neutral-700 hover:bg-neutral-800\"\n : \"border-neutral-300 hover:bg-neutral-100\";\n const paginationBtnActive =\n theme === \"dark\"\n ? \"bg-neutral-100 text-neutral-900 border-neutral-300\"\n : \"bg-neutral-900 text-white border-neutral-700\";\n const paginationBtnDisabled =\n \"cursor-not-allowed opacity-40 border-neutral-700\";\n const inputBorder =\n theme === \"dark\" ? \"border-neutral-700\" : \"border-neutral-300\";\n\n const pageSizeSelectId = useId();\n\n /* ----- header keyboard roving ------------------------------------------------------- */\n const handleHeaderKeyDown = useCallback(\n (e: React.KeyboardEvent<HTMLTableSectionElement>) => {\n if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n const thead = theadRef.current;\n if (!thead) return;\n const buttons = Array.from(thead.querySelectorAll(\"button\"));\n const index = buttons.indexOf(e.target as HTMLButtonElement);\n if (index === -1) return;\n const next = buttons[index + (e.key === \"ArrowRight\" ? 1 : -1)];\n if (next) {\n e.preventDefault();\n next.focus();\n }\n },\n []\n );\n\n /* ----- cell render helpers ------------------------------------------------------------ */\n const renderCellValue = useCallback(\n (col: DataTableColumn<T>, row: T): React.ReactNode => {\n if (col.cell) return col.cell(row);\n const value = col.accessor(row);\n if (value == null) return \"\";\n return String(value);\n },\n []\n );\n\n const leafStickyStyle = useCallback(\n (leafIndex: number, isHeader: boolean): React.CSSProperties => {\n const leftOffset = freezeOffsets.left.get(leafIndex);\n const rightOffset = freezeOffsets.right.get(leafIndex);\n const style: React.CSSProperties = {};\n const width = freezeOffsets.widths[leafIndex];\n const col = leafColumns[leafIndex];\n if (col?.width) {\n style.width = col.width;\n style.minWidth = col.width;\n }\n if (leftOffset === undefined && rightOffset === undefined) return style;\n style.position = \"sticky\";\n style.zIndex = isHeader ? 30 : 10;\n style.width = width;\n style.minWidth = width;\n if (leftOffset !== undefined) {\n style.left = leftOffset;\n if (leftOffset > 0) {\n style.boxShadow = \"4px 0 6px -2px rgba(0,0,0,0.1)\";\n }\n } else if (rightOffset !== undefined) {\n style.right = rightOffset;\n style.boxShadow = \"-4px 0 6px -2px rgba(0,0,0,0.1)\";\n }\n return style;\n },\n [freezeOffsets, leafColumns]\n );\n\n const leadingStickyStyle = useCallback(\n (position: number, isHeader: boolean): React.CSSProperties => {\n const style: React.CSSProperties = {\n width: LEADING_COL_WIDTH_PX,\n minWidth: LEADING_COL_WIDTH_PX,\n };\n if (anyFreezeLeft) {\n style.position = \"sticky\";\n style.left = position * LEADING_COL_WIDTH_PX;\n style.zIndex = isHeader ? 30 : 10;\n }\n return style;\n },\n [anyFreezeLeft]\n );\n\n const alignClass = (col: DataTableColumn<T>) =>\n col.align === \"right\"\n ? \"text-right\"\n : col.align === \"center\"\n ? \"text-center\"\n : undefined;\n\n /* ----- row rendering --------------------------------------------------------------------- */\n const renderDataRow = (\n row: T,\n visibleIndex: number,\n options: { ariaRowIndex?: number; pinned?: boolean } = {}\n ) => {\n const id = getRowId(row);\n const isSelected = selectableEnabled && selectedSet.has(id);\n const isExpanded = expandEnabled && expandedSet.has(id);\n const pinnedStyle: React.CSSProperties | undefined = options.pinned\n ? { position: \"sticky\", top: headerHeight, zIndex: 15 }\n : undefined;\n\n const cells = leafColumns.map((col, leafIndex) => {\n const plan = rowSpanPlans?.get(col.id);\n if (plan && !options.pinned) {\n const span = plan[visibleIndex];\n if (span === 0) return null;\n if (span > 1) {\n return (\n <td\n key={col.id}\n rowSpan={span}\n className={cn(\n \"px-4 py-3 align-top\",\n bodyText,\n bodyBg,\n cellBorderClass,\n alignClass(col)\n )}\n style={leafStickyStyle(leafIndex, false)}\n >\n {renderCellValue(col, row)}\n </td>\n );\n }\n }\n return (\n <td\n key={col.id}\n className={cn(\n \"px-4 py-3\",\n bodyText,\n bodyBg,\n cellBorderClass,\n alignClass(col)\n )}\n style={leafStickyStyle(leafIndex, false)}\n >\n {renderCellValue(col, row)}\n </td>\n );\n });\n\n return (\n <React.Fragment key={id}>\n <tr\n data-row=\"\"\n aria-rowindex={options.ariaRowIndex}\n aria-selected={selectableEnabled ? isSelected : undefined}\n className={cn(bodyBg, border && cn(\"border-b\", borderColor))}\n style={pinnedStyle}\n >\n {selectableEnabled && (\n <td\n className={cn(\"px-3 py-3\", bodyBg, cellBorderClass)}\n style={leadingStickyStyle(0, false)}\n >\n <input\n type=\"checkbox\"\n aria-label=\"Select row\"\n className=\"size-4 accent-current\"\n checked={isSelected}\n onChange={() => toggleRowSelected(id)}\n />\n </td>\n )}\n {expandEnabled && (\n <td\n className={cn(\"px-2 py-3\", bodyBg, cellBorderClass)}\n style={leadingStickyStyle(selectableEnabled ? 1 : 0, false)}\n >\n <button\n type=\"button\"\n aria-label=\"Expand row\"\n aria-expanded={isExpanded}\n onClick={() => toggleRowExpanded(id)}\n className={cn(\n \"inline-flex size-6 items-center justify-center rounded-sm\",\n bodyText,\n \"hover:opacity-80 outline-none focus-visible:ring-2\",\n theme === \"dark\"\n ? \"focus-visible:ring-neutral-600\"\n : \"focus-visible:ring-neutral-400\"\n )}\n >\n <ChevronDown\n className={cn(\n \"size-4 motion-safe:transition-transform\",\n isExpanded && \"rotate-180\"\n )}\n aria-hidden\n />\n </button>\n </td>\n )}\n {cells}\n </tr>\n {isExpanded && (\n <tr\n data-row-expansion=\"\"\n ref={virtual ? measureExpandedRow(id) : undefined}\n className={cn(bodyBg, border && cn(\"border-b\", borderColor))}\n >\n <td\n colSpan={fullColSpan}\n className={cn(\"px-4 py-3\", bodyText, bodyBg, cellBorderClass)}\n >\n {renderExpanded?.(row)}\n </td>\n </tr>\n )}\n </React.Fragment>\n );\n };\n\n /* ----- header rendering --------------------------------------------------------------------- */\n const stickyHeaderClass = stickyHeaderEnabled ? \"sticky top-0 z-20\" : \"\";\n\n const renderHeader = () => (\n <thead ref={theadRef} onKeyDown={handleHeaderKeyDown}>\n {headerRows.map((cells, level) => (\n <tr key={level} className={headerBg}>\n {level === 0 && selectableEnabled && (\n <th\n scope=\"col\"\n rowSpan={headerDepth}\n className={cn(\n \"px-3 py-3\",\n headerText,\n headerBg,\n cellBorderClass,\n stickyHeaderClass\n )}\n style={leadingStickyStyle(0, true)}\n >\n <input\n type=\"checkbox\"\n aria-label=\"Select all rows\"\n className=\"size-4 accent-current\"\n checked={allFilteredSelected}\n ref={(el) => {\n if (el) {\n el.indeterminate =\n someFilteredSelected && !allFilteredSelected;\n }\n }}\n onChange={toggleSelectAll}\n />\n </th>\n )}\n {level === 0 && expandEnabled && (\n <th\n scope=\"col\"\n rowSpan={headerDepth}\n aria-label=\"Row expansion\"\n className={cn(\n \"px-2 py-3\",\n headerText,\n headerBg,\n cellBorderClass,\n stickyHeaderClass\n )}\n style={leadingStickyStyle(selectableEnabled ? 1 : 0, true)}\n />\n )}\n {cells.map((cell) => {\n const col = cell.column;\n const canSort =\n cell.leaf && (col.sortable ?? tableSortable) && !col.columns;\n const rule = sortRules.find((r) => r.id === col.id);\n const sortPriority =\n rule && sortRules.length > 1\n ? sortRules.indexOf(rule) + 1\n : null;\n const ariaSort = rule\n ? rule.direction === \"asc\"\n ? \"ascending\"\n : \"descending\"\n : undefined;\n return (\n <th\n key={col.id}\n scope={cell.leaf ? \"col\" : \"colgroup\"}\n colSpan={cell.colSpan > 1 ? cell.colSpan : undefined}\n rowSpan={cell.rowSpan > 1 ? cell.rowSpan : undefined}\n aria-sort={canSort ? ariaSort : undefined}\n className={cn(\n \"px-4 py-3 font-medium whitespace-nowrap\",\n !cell.leaf && \"text-center\",\n headerText,\n headerBg,\n cellBorderClass,\n stickyHeaderClass,\n alignClass(col)\n )}\n style={cell.leaf ? leafStickyStyle(cell.leafIndex, true) : undefined}\n >\n {canSort ? (\n <button\n type=\"button\"\n onClick={(e) => toggleSort(col.id, e.shiftKey)}\n className={cn(\n \"inline-flex w-full max-w-full items-center gap-1 rounded-sm bg-transparent p-0 text-left font-inherit\",\n headerText,\n \"cursor-pointer select-none hover:opacity-80\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-offset-2\",\n theme === \"dark\"\n ? \"focus-visible:ring-neutral-600 focus-visible:ring-offset-neutral-950\"\n : \"focus-visible:ring-neutral-400 focus-visible:ring-offset-white\"\n )}\n >\n {col.header}\n {rule && (\n <span className=\"ml-1 inline-flex items-center\" aria-hidden>\n {rule.direction === \"asc\" ? \"↑\" : \"↓\"}\n {sortPriority != null && (\n <span className=\"ml-0.5 text-[10px] tabular-nums opacity-70\">\n {sortPriority}\n </span>\n )}\n </span>\n )}\n </button>\n ) : (\n col.header\n )}\n </th>\n );\n })}\n </tr>\n ))}\n </thead>\n );\n\n /* ----- body rendering ----------------------------------------------------------------------- */\n const renderBody = () => {\n const pinned = pinnedData.map((row, i) =>\n renderDataRow(row, i, { pinned: true })\n );\n\n if (virtual && windowSlice) {\n return (\n <tbody>\n {pinned}\n {windowSlice.padTop > 0 && (\n <tr data-spacer=\"\" aria-hidden=\"true\">\n <td\n colSpan={fullColSpan}\n style={{ height: windowSlice.padTop, padding: 0, border: \"none\" }}\n />\n </tr>\n )}\n {visibleRows.map((row, i) =>\n renderDataRow(row, i, {\n ariaRowIndex: windowSlice.start + i + 2,\n })\n )}\n {windowSlice.padBottom > 0 && (\n <tr data-spacer=\"\" aria-hidden=\"true\">\n <td\n colSpan={fullColSpan}\n style={{\n height: windowSlice.padBottom,\n padding: 0,\n border: \"none\",\n }}\n />\n </tr>\n )}\n </tbody>\n );\n }\n\n if (groupedRows) {\n return (\n <tbody>\n {pinned}\n {groupedRows.map((group) => {\n const collapsed = collapsedGroups.has(group.key);\n return (\n <React.Fragment key={group.key}>\n <tr\n data-row-group=\"\"\n className={cn(headerBg, border && cn(\"border-b\", borderColor))}\n >\n <td colSpan={fullColSpan} className={cn(\"px-4 py-2\", headerText)}>\n <button\n type=\"button\"\n aria-expanded={!collapsed}\n onClick={() => toggleGroup(group.key)}\n className={cn(\n \"inline-flex items-center gap-2 font-medium\",\n headerText,\n \"cursor-pointer select-none hover:opacity-80 outline-none focus-visible:ring-2\",\n theme === \"dark\"\n ? \"focus-visible:ring-neutral-600\"\n : \"focus-visible:ring-neutral-400\"\n )}\n >\n <ChevronDown\n className={cn(\n \"size-4 motion-safe:transition-transform\",\n collapsed && \"-rotate-90\"\n )}\n aria-hidden\n />\n {group.key || \"—\"}\n <span className=\"text-xs opacity-70 tabular-nums\">\n {group.rows.length}\n </span>\n </button>\n </td>\n </tr>\n {!collapsed &&\n group.rows.map((row, i) => renderDataRow(row, i))}\n </React.Fragment>\n );\n })}\n </tbody>\n );\n }\n\n return (\n <tbody>\n {pinned}\n {visibleRows.map((row, i) => renderDataRow(row, i))}\n </tbody>\n );\n };\n\n /* ----- pagination footer ----------------------------------------------------------------------- */\n const pageItems = useMemo(\n () => getPaginationItems(safePage, totalPages),\n [safePage, totalPages]\n );\n\n const scrollStyle: React.CSSProperties = {};\n if (maxHeight !== undefined) {\n scrollStyle.maxHeight = maxHeight;\n } else if (virtual) {\n scrollStyle.maxHeight = DEFAULT_VIRTUAL_MAX_HEIGHT;\n }\n\n return (\n <div\n className={cn(\"w-full rounded-lg\", className)}\n data-engine={virtual ? \"virtual\" : \"standard\"}\n >\n {searchEnabled && (\n <div className=\"mb-3 flex items-center justify-between gap-3\">\n <div\n className={cn(\n \"flex h-9 w-full max-w-xs items-center gap-2 rounded-md border px-3\",\n inputBorder,\n bodyText\n )}\n >\n <Search className=\"size-4 shrink-0 opacity-60\" aria-hidden />\n <input\n type=\"search\"\n aria-label=\"Search table\"\n placeholder=\"Search…\"\n value={searchInput}\n onChange={(e) => setSearchInput(e.target.value)}\n className={cn(\n \"w-full bg-transparent text-sm outline-none placeholder:opacity-50\",\n bodyText\n )}\n />\n </div>\n {selectableEnabled && selectedSet.size > 0 && (\n <div className={cn(\"shrink-0 text-xs tabular-nums\", bodyText)}>\n {selectedSet.size} selected\n </div>\n )}\n </div>\n )}\n\n <div\n ref={viewportRef}\n data-table-viewport=\"\"\n onScroll={virtual ? handleScroll : undefined}\n className=\"w-full overflow-x-auto overflow-y-auto\"\n style={scrollStyle}\n >\n <table\n ref={tableRef}\n aria-label=\"Data table\"\n aria-rowcount={virtual ? sortedData.length + 1 : undefined}\n className={cn(\"w-full min-w-max text-left text-sm\", borderClass)}\n >\n {renderHeader()}\n {renderBody()}\n </table>\n </div>\n\n {totalItems === 0 && data.length > 0 && (\n <div className={cn(\"px-4 py-6 text-center text-sm\", bodyText)}>\n No matching rows.\n </div>\n )}\n\n {paginatedEnabled && totalItems > 0 && (\n <nav\n className={cn(\n \"mt-4 flex flex-col items-center justify-between gap-3 text-xs sm:flex-row sm:text-sm\",\n bodyText\n )}\n aria-label=\"Table pagination\"\n >\n <div className=\"text-xs sm:text-sm\">\n {`Showing ${pageStartIndex + 1}-${pageEndIndex} of ${totalItems}`}\n </div>\n <div className=\"flex items-center gap-3\">\n {pageSizeOptions && pageSizeOptions.length > 0 && (\n <div className=\"flex items-center gap-1\">\n <label htmlFor={pageSizeSelectId} className=\"hidden sm:inline\">\n Rows per page\n </label>\n <select\n id={pageSizeSelectId}\n className={cn(\n \"h-8 rounded-md border bg-transparent px-2 text-xs outline-none sm:text-sm\",\n inputBorder\n )}\n value={effectivePageSize}\n onChange={(e) => {\n setCurrentPageSize(\n normalizePageSize(e.target.value, effectivePageSize)\n );\n setCurrentPage(1);\n }}\n >\n {pageSizeOptions.map((size) => (\n <option key={size} value={size}>\n {size}\n </option>\n ))}\n </select>\n </div>\n )}\n\n <div className=\"flex items-center gap-1\">\n <button\n type=\"button\"\n aria-label=\"Previous page\"\n className={cn(\n \"inline-flex h-8 min-w-8 shrink-0 items-center justify-center rounded-md border px-2 text-xs leading-none sm:text-sm\",\n safePage === 1 ? paginationBtnDisabled : paginationBtnBase\n )}\n onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}\n disabled={safePage === 1}\n >\n <ChevronLeft className=\"size-4\" aria-hidden />\n </button>\n {pageItems.map((item, itemIdx) =>\n item === \"ellipsis\" ? (\n <span\n key={`ellipsis-${itemIdx}`}\n className=\"inline-flex h-8 min-w-8 shrink-0 items-center justify-center px-1 text-xs tabular-nums text-neutral-500 sm:text-sm\"\n aria-hidden\n >\n …\n </span>\n ) : (\n <button\n key={item}\n type=\"button\"\n className={cn(\n \"inline-flex h-8 min-w-8 shrink-0 items-center justify-center rounded-md border px-2 text-xs leading-none tabular-nums sm:text-sm\",\n item === safePage ? paginationBtnActive : paginationBtnBase\n )}\n aria-current={item === safePage ? \"page\" : undefined}\n onClick={() => setCurrentPage(item)}\n >\n {item}\n </button>\n )\n )}\n <button\n type=\"button\"\n aria-label=\"Next page\"\n className={cn(\n \"inline-flex h-8 min-w-8 shrink-0 items-center justify-center rounded-md border px-2 text-xs leading-none sm:text-sm\",\n safePage === totalPages\n ? paginationBtnDisabled\n : paginationBtnBase\n )}\n onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}\n disabled={safePage === totalPages}\n >\n <ChevronRight className=\"size-4\" aria-hidden />\n </button>\n </div>\n </div>\n </nav>\n )}\n </div>\n );\n}\n", |
There was a problem hiding this comment.
Pinned rows break virtual row indexing and expanded spacer math.
Line 11: the virtual window is computed against flowData, but expanded-height compensation uses indices from sortedData, and virtual aria-rowindex is not offset by pinned rows. With pinnedRows, this yields incorrect spacer compensation and incorrect row indices.
Suggested fix direction
- const rowIdIndex = useMemo(() => {
+ const flowRowIndex = useMemo(() => {
const map = new Map<string, number>();
- sortedData.forEach((row, i) => map.set(getRowId(row), i));
+ flowData.forEach((row, i) => map.set(getRowId(row), i));
return map;
- }, [sortedData, getRowId]);
+ }, [flowData, getRowId]);
// in windowSlice compensation:
- const index = rowIdIndex.get(id);
+ const index = flowRowIndex.get(id);
+ const pinnedCount = pinnedData.length;
// when rendering pinned rows:
- renderDataRow(row, i, { pinned: true })
+ renderDataRow(row, i, { pinned: true, ariaRowIndex: i + 2 })
// when rendering virtual flow rows:
- ariaRowIndex: windowSlice.start + i + 2
+ ariaRowIndex: pinnedCount + windowSlice.start + i + 2🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/www/public/registry/data-table.json` at line 11, The virtual window
computation uses flowData (which excludes pinned rows) to calculate visible row
ranges, but the expanded-height compensation logic checks row indices against
rowIdIndex (which maps to sortedData including all rows), and the aria-rowindex
for virtual rendering doesn't account for pinned rows. Fix this by: (1)
adjusting the windowSlice useMemo to track which rows are pinned when
compensating for expanded heights, ensuring the index checks work against the
actual visible data rather than sortedData; (2) updating the aria-rowindex
calculation in the renderDataRow function to add the count of pinned rows to
account for their presence in the DOM; and (3) verifying that the expanded
height tracking and spacer math correctly handle the offset between flowData
indices and the complete row order when pinnedSet.size > 0.
| expect(rendered.some((text) => text.includes("Person 100"))).toBe(true); | ||
| expect(rendered.some((text) => text.includes("Person 0 "))).toBe(false); | ||
| }); |
There was a problem hiding this comment.
Strengthen the negative scroll assertion to avoid formatting-dependent false positives.
Line 380 checks absence using includes("Person 0 "), which depends on exact whitespace in concatenated cell text. This can pass even when row 0 rendering regresses with different text formatting. Assert semantic absence instead (e.g., word-boundary match on joined text or direct cell lookup by row id/name token).
As per coding guidelines: Write tests that encode WHY behavior matters, not just WHAT it does; ensure tests can fail when business logic changes.
Suggested patch
- expect(rendered.some((text) => text.includes("Person 0 "))).toBe(false);
+ expect(rendered.join(" ")).not.toMatch(/\bPerson 0\b/);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| expect(rendered.some((text) => text.includes("Person 100"))).toBe(true); | |
| expect(rendered.some((text) => text.includes("Person 0 "))).toBe(false); | |
| }); | |
| expect(rendered.some((text) => text.includes("Person 100"))).toBe(true); | |
| expect(rendered.join(" ")).not.toMatch(/\bPerson 0\b/); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/www/tests/data-table.test.tsx` around lines 379 - 381, The assertion on
line 380 that checks for the absence of "Person 0 " using includes() is fragile
because it depends on exact whitespace formatting in the concatenated cell text.
Replace this string-matching approach with a more semantic assertion that checks
for the actual absence of row 0 content using word boundaries or by directly
looking up specific cell values by row identifier or token matching. This
ensures the test will reliably fail when row 0 is actually rendered, regardless
of how the text formatting changes.
Source: Coding guidelines
| // Feature flags — a preset sets defaults; explicit props override. | ||
| searchable?: boolean; | ||
| paginated?: boolean; | ||
| pageSize?: number; | ||
| pageSizeOptions?: number[]; | ||
| multiSort?: boolean; | ||
| selectable?: boolean; | ||
| selectedIds?: string[]; // controlled selection | ||
| onSelectionChange?: (ids: string[]) => void; | ||
| expandable?: boolean; | ||
| renderExpanded?: (row: T) => React.ReactNode; | ||
| expandedIds?: string[]; // controlled expansion | ||
| onExpandedChange?: (ids: string[]) => void; | ||
| groupBy?: string; // column id → standard engine, collapsible groups | ||
| virtualized?: boolean; // force on/off; default = auto | ||
| virtualizeThreshold?: number; // default 100 | ||
| rowHeight?: number; // estimate for windowing; default ~44 | ||
| maxHeight?: number | string; // scroll container height | ||
| stickyHeader?: boolean; | ||
| pinnedRows?: string[]; // row ids pinned under the header | ||
| onSort?: (sort: Array<{ id: string; direction: SortDirection }>) => void; | ||
|
|
||
| // Existing visual props carried forward: | ||
| theme?: "light" | "dark"; | ||
| border?: boolean; | ||
| headerTextColor?, bodyTextColor?, headerBackground?, bodyBackground?: string; | ||
| className?: string; |
There was a problem hiding this comment.
Align the spec API snippet with the implemented DataTableProps contract.
There are a few contract drifts:
- Line 55-Line 76 omits
sortable?: booleaneven though the component type exposes it. - Line 80 uses invalid TS shorthand (
headerTextColor?, ...?: string) instead of per-field?: string. - Line 110-Line 112 mentions forced-virtual conflicts for
rowSpan/groupBy, but runtime logic also treatspaginatedas conflicting.
Also applies to: 109-112
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/superpowers/specs/2026-06-09-data-table-v2-design.md` around lines 55 -
81, The specification document has three alignment issues with the implemented
DataTableProps: First, add the missing sortable?: boolean property to the
feature flags section (after line 55-76) since the component exposes it. Second,
fix the invalid TypeScript shorthand on line 80 by replacing the shorthand
syntax (headerTextColor?, bodyTextColor?, headerBackground?, bodyBackground?:
string) with explicit per-field declarations using proper ?: string type
annotations for each property individually. Third, update the forced-virtual
conflicts documentation (lines 110-112) to also mention that paginated conflicts
with virtualization, not just rowSpan and groupBy.
| export interface DataTableProps<T> { | ||
| data: T[]; | ||
| columns: DataTableColumn<T>[]; | ||
| /** Stable row id — selection, expansion, pinning, and windowing keys. */ | ||
| getRowId: (row: T) => string; | ||
|
|
||
| /** Bundles feature defaults; any explicit prop overrides the preset. */ | ||
| preset?: DataTablePreset; | ||
|
|
||
| searchable?: boolean; | ||
| paginated?: boolean; | ||
| pageSize?: number; | ||
| pageSizeOptions?: number[]; | ||
| sortable?: boolean; | ||
| multiSort?: boolean; | ||
| onSort?: (sort: DataTableSortRule[]) => void; | ||
| selectable?: boolean; | ||
| selectedIds?: string[]; | ||
| onSelectionChange?: (ids: string[]) => void; | ||
| expandable?: boolean; | ||
| renderExpanded?: (row: T) => React.ReactNode; | ||
| expandedIds?: string[]; | ||
| onExpandedChange?: (ids: string[]) => void; | ||
| /** Column id to group rows by — switches to the standard engine. */ | ||
| groupBy?: string; | ||
| /** Force the engine; defaults to automatic selection. */ | ||
| virtualized?: boolean; | ||
| /** Row count above which flat data is windowed. Default 100. */ | ||
| virtualizeThreshold?: number; | ||
| /** Estimated row height in px used for windowing math. Default 44. */ | ||
| rowHeight?: number; | ||
| /** Scroll container height; required for a useful virtualized view. */ | ||
| maxHeight?: number | string; | ||
| stickyHeader?: boolean; | ||
| /** Row ids kept visible directly below the header. */ | ||
| pinnedRows?: string[]; | ||
|
|
||
| theme?: "light" | "dark"; | ||
| border?: boolean; | ||
| headerTextColor?: string; | ||
| bodyTextColor?: string; | ||
| headerBackground?: string; | ||
| bodyBackground?: string; | ||
| className?: string; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Extend and forward root <div> props.
DataTableProps only exposes className, so registry consumers cannot pass root id, style, role, aria-*, or data-* attributes to the wrapper rendered below. Extend the relevant div props and spread the rest onto the root.
♻️ Proposed patch
-export interface DataTableProps<T> {
+export interface DataTableProps<T>
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
data: T[];
columns: DataTableColumn<T>[];
/** Stable row id — selection, expansion, pinning, and windowing keys. */
getRowId: (row: T) => string;
@@
headerBackground?: string;
bodyBackground?: string;
- className?: string;
}
@@
headerBackground,
bodyBackground,
className,
+ ...rootProps
}: DataTableProps<T>) {
@@
<div
+ {...rootProps}
className={cn("w-full rounded-lg", className)}
data-engine={virtual ? "virtual" : "standard"}
>As per coding guidelines, registry/**/*.tsx: "Extend the relevant HTML/motion props interface and omit conflicting event handlers in TypeScript components."
Also applies to: 1302-1305
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@registry/data-table/component.tsx` around lines 58 - 102, The DataTableProps
interface is too restrictive and only exposes className, preventing consumers
from passing standard HTML attributes like id, style, role, aria-*, and data-*
to the root wrapper. Extend DataTableProps to include relevant HTML div
properties (such as React.HTMLAttributes<HTMLDivElement>) while omitting any
conflicting event handlers per coding guidelines, then spread the resulting
props onto the root div element in the component. This pattern should be applied
consistently across all affected locations in the file.
Source: Coding guidelines
| const rowIdIndex = useMemo(() => { | ||
| const map = new Map<string, number>(); | ||
| sortedData.forEach((row, i) => map.set(getRowId(row), i)); | ||
| return map; | ||
| }, [sortedData, getRowId]); |
There was a problem hiding this comment.
Use flow-row indexes for virtual expansion spacer math.
The virtual window is computed against flowData, but expanded-row spacer adjustment reads indices from sortedData. Once rows are pinned, those coordinate systems diverge; pinned expanded rows can also be counted even though they sit outside the virtual flow.
registry/data-table/component.tsx#L521-L525: build the index map fromflowDataafter pinned rows are split out, or add a separateflowRowIdIndex.registry/data-table/component.tsx#L691-L696: look up expanded IDs in the flow map; missing IDs are pinned and should not adjust virtual spacers.apps/www/components/ui/data-table.tsx#L521-L525: mirror the same flow-based index map.apps/www/components/ui/data-table.tsx#L691-L696: mirror the same flow-map lookup.
🐛 Suggested direction
- const rowIdIndex = useMemo(() => {
- const map = new Map<string, number>();
- sortedData.forEach((row, i) => map.set(getRowId(row), i));
- return map;
- }, [sortedData, getRowId]);
+ // Define after `flowData` is computed.
+ const flowRowIdIndex = useMemo(() => {
+ const map = new Map<string, number>();
+ flowData.forEach((row, i) => map.set(getRowId(row), i));
+ return map;
+ }, [flowData, getRowId]);
@@
- const index = rowIdIndex.get(id);
+ const index = flowRowIdIndex.get(id);
if (index === undefined) continue;
@@
- rowIdIndex,
+ flowRowIdIndex,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const rowIdIndex = useMemo(() => { | |
| const map = new Map<string, number>(); | |
| sortedData.forEach((row, i) => map.set(getRowId(row), i)); | |
| return map; | |
| }, [sortedData, getRowId]); | |
| const flowRowIdIndex = useMemo(() => { | |
| const map = new Map<string, number>(); | |
| flowData.forEach((row, i) => map.set(getRowId(row), i)); | |
| return map; | |
| }, [flowData, getRowId]); |
📍 Affects 2 files
registry/data-table/component.tsx#L521-L525(this comment)registry/data-table/component.tsx#L691-L696apps/www/components/ui/data-table.tsx#L521-L525apps/www/components/ui/data-table.tsx#L691-L696
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@registry/data-table/component.tsx` around lines 521 - 525, The rowIdIndex
useMemo is currently built from sortedData, but the virtual window is computed
against flowData, causing coordinate system misalignment when rows are pinned.
In registry/data-table/component.tsx at lines 521-525, change the rowIdIndex to
build from flowData instead of sortedData (or create a separate flowRowIdIndex).
Then in the same file at lines 691-696 where expanded-row spacer adjustment
occurs, update the lookup logic to use the flow-based index map and skip any IDs
that are missing from the map, as those represent pinned rows that should not
adjust virtual spacers. Mirror these exact same changes in
apps/www/components/ui/data-table.tsx at lines 521-525 and 691-696.
| const filteredIds = useMemo( | ||
| () => sortedData.map((row) => getRowId(row)), | ||
| [sortedData, getRowId] | ||
| ); | ||
| const allFilteredSelected = | ||
| filteredIds.length > 0 && filteredIds.every((id) => selectedSet.has(id)); | ||
| const someFilteredSelected = filteredIds.some((id) => selectedSet.has(id)); | ||
| const toggleSelectAll = useCallback(() => { | ||
| updateSelection(allFilteredSelected ? new Set() : new Set(filteredIds)); | ||
| }, [allFilteredSelected, filteredIds, updateSelection]); |
There was a problem hiding this comment.
Preserve selections outside the current filtered set.
The shared root cause is replacing the whole selection set when toggling the header checkbox. With an active search, this drops IDs that were already selected but are not in filteredIds, and onSelectionChange receives a truncated selection.
registry/data-table/component.tsx#L551-L560: derivenextfromselectedSet, then add/delete only the currentfilteredIds.apps/www/components/ui/data-table.tsx#L551-L560: mirror the same merge/diff behavior.
🐛 Proposed patch for both files
const toggleSelectAll = useCallback(() => {
- updateSelection(allFilteredSelected ? new Set() : new Set(filteredIds));
- }, [allFilteredSelected, filteredIds, updateSelection]);
+ const next = new Set(selectedSet);
+ if (allFilteredSelected) {
+ filteredIds.forEach((id) => next.delete(id));
+ } else {
+ filteredIds.forEach((id) => next.add(id));
+ }
+ updateSelection(next);
+ }, [allFilteredSelected, filteredIds, selectedSet, updateSelection]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const filteredIds = useMemo( | |
| () => sortedData.map((row) => getRowId(row)), | |
| [sortedData, getRowId] | |
| ); | |
| const allFilteredSelected = | |
| filteredIds.length > 0 && filteredIds.every((id) => selectedSet.has(id)); | |
| const someFilteredSelected = filteredIds.some((id) => selectedSet.has(id)); | |
| const toggleSelectAll = useCallback(() => { | |
| updateSelection(allFilteredSelected ? new Set() : new Set(filteredIds)); | |
| }, [allFilteredSelected, filteredIds, updateSelection]); | |
| const filteredIds = useMemo( | |
| () => sortedData.map((row) => getRowId(row)), | |
| [sortedData, getRowId] | |
| ); | |
| const allFilteredSelected = | |
| filteredIds.length > 0 && filteredIds.every((id) => selectedSet.has(id)); | |
| const someFilteredSelected = filteredIds.some((id) => selectedSet.has(id)); | |
| const toggleSelectAll = useCallback(() => { | |
| const next = new Set(selectedSet); | |
| if (allFilteredSelected) { | |
| filteredIds.forEach((id) => next.delete(id)); | |
| } else { | |
| filteredIds.forEach((id) => next.add(id)); | |
| } | |
| updateSelection(next); | |
| }, [allFilteredSelected, filteredIds, selectedSet, updateSelection]); |
📍 Affects 2 files
registry/data-table/component.tsx#L551-L560(this comment)apps/www/components/ui/data-table.tsx#L551-L560
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@registry/data-table/component.tsx` around lines 551 - 560, The
toggleSelectAll callback in both files replaces the entire selection set, losing
selections outside the current filtered set when searching is active. Modify the
toggleSelectAll callback logic in both registry/data-table/component.tsx (lines
551-560) and apps/www/components/ui/data-table.tsx (lines 551-560) to preserve
existing selections by deriving the next selection from the current selectedSet
and only adding or removing the filteredIds accordingly. When
allFilteredSelected is true, create a new Set from selectedSet and delete the
filteredIds from it; when false, create a new Set from selectedSet and add the
filteredIds to it. Pass this merged/diffed selection to updateSelection instead
of replacing the entire set.
| const rowSpanPlans = useMemo(() => { | ||
| if (virtual || !anyRowSpan) return null; | ||
| const plans = new Map<string, number[]>(); | ||
| for (const col of leafColumns) { | ||
| if (col.rowSpan) plans.set(col.id, computeRowSpans(visibleRows, col)); | ||
| } | ||
| return plans; | ||
| }, [virtual, anyRowSpan, leafColumns, visibleRows]); |
There was a problem hiding this comment.
Compute row spans in the grouped display order.
rowSpanPlans is generated for visibleRows, then grouped rendering re-buckets rows and passes a group-local i into renderDataRow. Combining groupBy and rowSpan can therefore apply spans from unrelated rows and render malformed merged cells.
registry/data-table/component.tsx#L755-L762: compute span plans against the rows as rendered, e.g. per group whengroupedRowsis active.registry/data-table/component.tsx#L1272-L1273: do not pass a group-local index into a plan generated for the fullvisibleRowsarray.apps/www/components/ui/data-table.tsx#L755-L762: mirror the grouped-order span computation.apps/www/components/ui/data-table.tsx#L1272-L1273: mirror the grouped render index fix.
📍 Affects 2 files
registry/data-table/component.tsx#L755-L762(this comment)registry/data-table/component.tsx#L1272-L1273apps/www/components/ui/data-table.tsx#L755-L762apps/www/components/ui/data-table.tsx#L1272-L1273
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@registry/data-table/component.tsx` around lines 755 - 762, The rowSpanPlans
computation is based on visibleRows but rendering uses group-local indices when
groupBy is active, causing row spans to apply across unrelated rows in different
groups. In registry/data-table/component.tsx#L755-L762 (anchor), modify the
useMemo dependency and computation to use groupedRows instead of visibleRows
when grouping is active, ensuring span plans align with the actual rendered row
order. In registry/data-table/component.tsx#L1272-L1273 (sibling), remove the
group-local index being passed into renderDataRow when accessing the
rowSpanPlans, using the correct global index instead. Apply identical fixes to
the mirrored code in apps/www/components/ui/data-table.tsx at lines 755-762 and
1272-1273 respectively.
| {rule && ( | ||
| <span className="ml-1 inline-flex items-center" aria-hidden> | ||
| {rule.direction === "asc" ? "↑" : "↓"} | ||
| {sortPriority != null && ( | ||
| <span className="ml-0.5 text-[10px] tabular-nums opacity-70"> | ||
| {sortPriority} | ||
| </span> | ||
| )} | ||
| </button> | ||
| ) : ( | ||
| col.label | ||
| </span> |
There was a problem hiding this comment.
Expose multi-sort priority to assistive tech.
The only priority indicator is inside an aria-hidden wrapper, while aria-sort only exposes direction. Screen-reader users cannot determine first/second/etc. sort priority in multi-sort mode.
registry/data-table/component.tsx#L1171-L1179: removearia-hiddenfrom the wrapper and expose the priority with contextual text, such asaria-label="sort priority 2"or ansr-onlylabel.apps/www/components/ui/data-table.tsx#L1171-L1179: mirror the same accessible priority label.
As per coding guidelines, **/*.{tsx,jsx}: "a11y notes must not hide content: Never advise aria-hidden on wrappers that contain readable children; restrict to decorative layers only."
📍 Affects 2 files
registry/data-table/component.tsx#L1171-L1179(this comment)apps/www/components/ui/data-table.tsx#L1171-L1179
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@registry/data-table/component.tsx` around lines 1171 - 1179, The sort
priority indicator is hidden from assistive technology by the aria-hidden
wrapper, making it inaccessible to screen reader users in multi-sort scenarios.
Remove the aria-hidden attribute from the span wrapper in both files and provide
accessible context for the priority number. In registry/data-table/component.tsx
at lines 1171-1179, replace the aria-hidden wrapper with an accessible solution
that exposes the sortPriority value through an aria-label (such as "sort
priority 2") or an sr-only class element containing descriptive priority text.
Mirror the identical change in apps/www/components/ui/data-table.tsx at lines
1171-1179 to ensure consistency across both implementations. The directional
indicator (↑/↓) may remain visual, but the priority number must be announced to
assistive technology.
Source: Coding guidelines
| placed: new Date(2026, i % 12, (i % 27) + 1), | ||
| })); |
There was a problem hiding this comment.
Avoid UTC-shifting calendar dates in the advanced demo.
Line 52 creates a local-time Date, but Line 85 formats it with toISOString(), which can display the previous/next day depending on timezone.
💡 Suggested fix
+ const formatDate = (d: Date) =>
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+
const rows = Array.from({ length: 24 }, (_, i) => ({
id: `ord-${i + 1}`,
order: `#${String(2400 + i)}`,
customer: ["Alex Kim", "Sara Chen", "Jordan Lee", "Maya Patel", "Ryan Wu", "Priya Shah"][i % 6],
total: Math.round((i * 53.7 + 24) * 100) / 100,
status: ["Paid", "Pending", "Refunded"][i % 3],
placed: new Date(2026, i % 12, (i % 27) + 1),
}));
@@
{
id: "placed",
header: "Placed",
accessor: (row: Row) => row.placed,
- cell: (row: Row) => row.placed.toISOString().slice(0, 10),
+ cell: (row: Row) => formatDate(row.placed),
},Also applies to: 85-86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@registry/data-table/demo.tsx` around lines 52 - 53, The issue is that the
Date object created at lines 52-53 uses local time construction, which when
formatted with toISOString() at lines 85-86 will shift to UTC and potentially
display the wrong day depending on the user's timezone. To fix this, change the
Date constructor at lines 52-53 from the local-time Date constructor (new
Date(2026, i % 12, (i % 27) + 1)) to use Date.UTC() to create the date in UTC
coordinates, ensuring that when toISOString() is applied at lines 85-86 for
formatting, the displayed date matches the intended calendar date without
timezone shifting.
Type
Summary
Rebuilds DataTable as a typed, dual-engine grid: a standard DOM engine for small/grouped datasets and a virtualized engine for large ones, with automatic mode-switching. Adds column presets, multi-column sorting, grouping with collapsible group rows, and row selection so the component scales from tens to ~100k rows without consumers wiring up a third-party table library.
Test plan
pnpm test(full turbo suite — 22 data-table tests + all workspaces pass via precommit hook)apps/wwwlint (precommit hook)pnpm build:registryapps/wwwdev serverNew component checklist
Registry sources
registry/data-table/component.tsxregistry/components/data-table.json—registry+docsblocksorder/docsOrderinregistry/manifest.jsonpnpm build:registryartifacts in syncComponent quality gate
"use client"where neededclassNameand merges viacnaria-sorton sortable headersapps/www/tests/data-table.test.tsx(22 tests: sorting, numeric/date sort, multi-sort, grouping, engine selection)Related issues
Summary by CodeRabbit