Skip to content

feat(data-table): rebuild as typed dual-engine virtualized grid#78

Merged
pras75299 merged 5 commits into
mainfrom
feat/data-table-enhance
Jun 20, 2026
Merged

feat(data-table): rebuild as typed dual-engine virtualized grid#78
pras75299 merged 5 commits into
mainfrom
feat/data-table-enhance

Conversation

@pras75299

@pras75299 pras75299 commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Type

  • New component / block (registry)
  • CLI change (init / add / list / info / doctor / search / shadcn JSON)
  • Docs site (apps/www)
  • Build / scripts / CI
  • Bug fix
  • Refactor / chore

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/www lint (precommit hook)
  • pnpm build:registry
  • Manually verified in apps/www dev server

New component checklist

Registry sources

  • registry/data-table/component.tsx
  • registry/components/data-table.jsonregistry + docs blocks
  • Slug present in order / docsOrder in registry/manifest.json
  • Demo present
  • pnpm build:registry artifacts in sync

Component quality gate

  • "use client" where needed
  • Accepts className and merges via cn
  • Props extend correct DOM type, conflicting handlers omitted
  • Keyboard operable + aria-sort on sortable headers
  • No cross-imports between registry components
  • Render/behavior tests in apps/www/tests/data-table.test.tsx (22 tests: sorting, numeric/date sort, multi-sort, grouping, engine selection)

Related issues

Summary by CodeRabbit

  • New Features

    • Rebuilt the DataTable with a typed, accessor-based column API and stable row IDs
    • Added global debounced search, multi-column sorting (shift-click), row selection with filtered-aware “select all,” and row expansion
    • Added row grouping (collapsible), row spanning, pinned rows, sticky headers, and multi-level column groups
    • Added optional windowed virtualization for large datasets and feature presets (basic/advanced/enterprise)
  • Bug Fixes

    • Improved virtualization spacing, accessibility row indexing, and correct alignment when combined with selection, expansion, search, and pinned rows
  • Documentation / Tests

    • Updated demos and docs; added comprehensive automated test coverage

@vercel

vercel Bot commented Jun 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
uniqueui-platform Ready Ready Preview, Comment Jun 20, 2026 11:26am

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pras75299, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 41 minutes and 13 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a18ea1bd-8e71-4d39-a59f-fca1ebb0aadf

📥 Commits

Reviewing files that changed from the base of the PR and between 419aacb and 25371be.

📒 Files selected for processing (6)
  • apps/www/components/ui/data-table.tsx
  • apps/www/public/r/data-table.json
  • apps/www/public/registry.json
  • apps/www/public/registry/data-table.json
  • registry.json
  • registry/data-table/component.tsx
📝 Walkthrough

Walkthrough

DataTable is rewritten from a non-generic v1 to a fully typed generic DataTable<T> (v2.0.0). The new component introduces accessor-based column definitions, required getRowId, a preset system, debounced global search, multi-column sorting, row selection/expansion, groupBy with collapsible groups, rowSpan merging, frozen columns with measured widths, pinned rows, sticky headers, and a hand-rolled virtualization engine that auto-switches to a standard engine for rich layout features.

Changes

DataTable v2 rewrite

Layer / File(s) Summary
Exported types, interfaces, and virtualization helpers
apps/www/components/ui/data-table.tsx, registry/data-table/component.tsx
Introduces all new public exports: SortDirection, DataTableSortRule, DataTablePreset, DataTableColumn<T>, DataTableProps<T>, WindowSlice, and the computeWindow virtualization math helper. Also updates lucide icon imports and adds internal sort-type inference plus page-size normalization utilities.
Header layout planning, row-span merging, and preset defaults
apps/www/components/ui/data-table.tsx, registry/data-table/component.tsx
Adds flattenColumns, getColumnDepth, buildHeaderRows (computing colSpan/rowSpan for multi-level headers), computeRowSpans for equal-value run merging, and PRESET_DEFAULTS mapping basic/advanced/enterprise to feature flag defaults.
DataTable<T> core engine: feature resolution, data pipeline, state management
apps/www/components/ui/data-table.tsx, registry/data-table/component.tsx
Implements the main component body: resolves feature flags from preset+props, emits runtime warnings for incompatible engine combinations, wires debounced search, multi-rule sorting with shift-click toggle and type inference, controlled/uncontrolled selection/expansion state, collapsible groupBy with group headers, pinned-row separation, and auto-selects between standard and virtualization rendering engines.
Pagination state and internal page navigation
apps/www/components/ui/data-table.tsx, registry/data-table/component.tsx
Manages internal currentPage state, computes compact pagination items with ellipsis strategy, and wires Previous/Next and page-number navigation buttons. Removes legacy onPageChange callback invocations and customizable pagination label props.
Demo helpers removal and new typed demo variants
apps/www/config/demos.tsx, registry/demos/shared.tsx, registry/data-table/demo.tsx, registry/demos/demo-key-order.json
Removes old data-table row-enrichment utilities (withDemoLocation, withDemoFreezeExtras). Introduces five new typed demo entries (basic, advanced, virtualized, grouped, column-groups), each exercising different presets and feature combinations with the new accessor-based column definitions.
Design specification and component documentation updates
docs/superpowers/specs/2026-06-09-data-table-v2-design.md, apps/www/config/components.ts, apps/www/config/docs-scenarios.ts, registry/components/data-table.json
Adds the comprehensive DataTable v2 design spec documenting the typed API, auto-switching engines, feature matrix, accessibility, and success criteria. Updates all authored documentation (registry component description, docs-scenarios code examples, component registry variants) to reflect the new typed API, preset model, and feature combinations.
Registry metadata regeneration and release metadata
apps/www/public/registry.json, apps/www/public/r/data-table.json, apps/www/public/registry/data-table.json, registry.json, registry/components/data-table.json, apps/www/public/registry/changelogs.json
Regenerates all public registry JSON artifacts with updated description, props schema, tags (grid, virtualized), and accessibility status (audited). Adds the new 2.0.0 changelog entry documenting breaking changes and new features. Updates relatedSlugs metadata for data-table and neighboring components.
Comprehensive Vitest coverage for DataTable v2
apps/www/tests/data-table.test.tsx
Adds 624 lines of test coverage: sort cycling/numeric/date/multi-sort with aria-sort and shift-click priority, debounced search filtering, selection modes (uncontrolled/controlled/select-all), expandable rows with aria-expanded, pagination slicing/clamping, virtualization window tracking with aria-rowcount/aria-rowindex, engine switching on groupBy, row-span merging, nested column-group header spans, preset feature enablement and prop overrides, and computeWindow math (overscan, spacer padding, centering, clamping).

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 filtered results
  DataTable->>DataTable: resolve engine: virtualized vs standard
  alt virtualized engine (flat data)
    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 for equal values
    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->>DataTable: pagination footer (Previous, page buttons, Next)
  DataTable-->>Consumer: rendered table + pagination + search input
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • pras75299/uniqueui#13: Directly touches apps/www/components/ui/data-table.tsx pagination and row-rendering logic, the same file whose pagination controls are restructured in this PR to use internal state instead of callbacks.
  • pras75299/uniqueui#25: Previously modified apps/www/components/ui/data-table.tsx column-freezing behavior with freezeLeftCount/freezeRightCount; this PR replaces that legacy API entirely with the new per-column freeze property.

Poem

🐇 Hop hop, the table grew wings so tall,
With accessor and getRowId for all!
Two engines hum — virtual or wide,
Shift-click to sort, and rows expand with pride.
The rabbit merged cells and froze columns tight,
computeWindow slices the data just right! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the primary change: rebuilding DataTable with typing and dual-engine virtualization support.
Description check ✅ Passed The description covers the key changes (typed API, dual engines, multi-sort, grouping, selection), test plan execution, and component quality checklist items, mostly following the template structure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/data-table-enhance

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (1)
registry/data-table/component.tsx (1)

490-515: Move onSort callback outside the state updater.

The function passed to setSortRules must be pure. Invoking onSort?.(next) inside it violates this requirement—React may call the updater function multiple times in Strict Mode, causing duplicate onSort invocations and unpredictable side effects. Calculate next in the pure updater, then invoke onSort in a useEffect hook or at the event handler level.

  • registry/data-table/component.tsx#L514: move onSort?.(next) out of the setSortRules((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

📥 Commits

Reviewing files that changed from the base of the PR and between c9853a8 and a29fea9.

📒 Files selected for processing (20)
  • apps/www/components/ui/data-table.tsx
  • apps/www/config/components.ts
  • apps/www/config/demos.tsx
  • apps/www/config/docs-scenarios.ts
  • apps/www/public/r/data-table.json
  • apps/www/public/r/registry.json
  • apps/www/public/registry.json
  • apps/www/public/registry/changelogs.json
  • apps/www/public/registry/data-table.json
  • apps/www/public/registry/dot-grid-background.json
  • apps/www/public/registry/particle-field.json
  • apps/www/public/registry/shooting-stars-grid.json
  • apps/www/tests/data-table.test.tsx
  • docs/superpowers/specs/2026-06-09-data-table-v2-design.md
  • registry.json
  • registry/components/data-table.json
  • registry/data-table/component.tsx
  • registry/data-table/demo.tsx
  • registry/demos/demo-key-order.json
  • registry/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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment thread apps/www/tests/data-table.test.tsx
Comment thread docs/superpowers/specs/2026-06-09-data-table-v2-design.md
Comment thread registry/data-table/component.tsx Outdated
Comment thread registry/data-table/component.tsx Outdated
Comment thread registry/data-table/component.tsx Outdated
Comment thread registry/data-table/component.tsx Outdated
Comment thread registry/data-table/component.tsx
Comment thread registry/data-table/demo.tsx
@pras75299 pras75299 merged commit 654a614 into main Jun 20, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant