@varialkit/table 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Table.tsx ADDED
@@ -0,0 +1,1380 @@
1
+ import React from 'react';
2
+ import {
3
+ flexRender,
4
+ getCoreRowModel,
5
+ getSortedRowModel,
6
+ useReactTable,
7
+ } from '@tanstack/react-table';
8
+ import type {
9
+ TableDensity,
10
+ TableGridType,
11
+ TableLayoutMode,
12
+ TableProps,
13
+ } from './Table.types';
14
+ import { Icon } from '@solara/icons';
15
+ import { Menu, MenuDropdown, MenuRow, MenuSeparator, MenuSubhead } from '@solara/menu';
16
+ import { Button } from '@solara/button';
17
+ import { Dropdown } from '@solara/dropdown';
18
+ import './Table.scss';
19
+
20
+ export function Table<TData>({
21
+ data,
22
+ columns,
23
+ tableOptions,
24
+ getRowId,
25
+ className,
26
+ tableClassName,
27
+ headerClassName,
28
+ bodyClassName,
29
+ headerActions,
30
+ enableColumnControls = false,
31
+ columnControlsLabel = 'Table settings',
32
+ actionMenuSurfaceStyle,
33
+ densityOptions = ['compact', 'standard', 'spacious'],
34
+ layoutModeOptions = ['list', 'grid'],
35
+ gridTypeOptions = ['grid', 'masonry'],
36
+ onDensityChange,
37
+ enableLayoutToggle = false,
38
+ onLayoutModeChange,
39
+ layoutMode = 'list',
40
+ rowClassName,
41
+ cellClassName,
42
+ headerCellClassName,
43
+ renderHeaderCell,
44
+ renderCell,
45
+ renderGridCell,
46
+ gridCellRenderers,
47
+ gridColumnConfig,
48
+ gridVisibleColumnIds,
49
+ onGridVisibleColumnIdsChange,
50
+ renderGridRow,
51
+ renderRow,
52
+ renderBody,
53
+ emptyState,
54
+ onRowClick,
55
+ stickyHeader = false,
56
+ transparentSticky = false,
57
+ transparentBackground = false,
58
+ showHeader = true,
59
+ density = 'standard',
60
+ wrapperProps,
61
+ tableProps,
62
+ getRowProps,
63
+ getCellProps,
64
+ getHeaderProps,
65
+ enableSorting = false,
66
+ showPagination = false,
67
+ paginationLabels,
68
+ paginationPageSizes = [5, 10, 20, 50],
69
+ gridMinColumnWidth = 260,
70
+ gridType = 'grid',
71
+ onGridTypeChange,
72
+ gridSpacing,
73
+ }: TableProps<TData>) {
74
+ const resolveSurfaceStyle = (value: string | null | undefined) => {
75
+ if (
76
+ value === 'solid' ||
77
+ value === 'translucent' ||
78
+ value === 'glass'
79
+ ) {
80
+ return value;
81
+ }
82
+ return undefined;
83
+ };
84
+
85
+ // Action menu renders in a portal, so we resolve surface style explicitly.
86
+ // This keeps translucent/glass treatment stable even when the table is hosted
87
+ // in shells that don't rely purely on root descendant CSS selectors.
88
+ const resolvedActionMenuSurfaceStyle =
89
+ actionMenuSurfaceStyle ??
90
+ (typeof document !== 'undefined'
91
+ ? resolveSurfaceStyle(
92
+ document.documentElement.getAttribute('data-surface-menu') ??
93
+ document.documentElement.getAttribute('data-surface-default')
94
+ )
95
+ : undefined);
96
+
97
+ // Allow the menu to drive density when the parent doesn't provide a handler.
98
+ const [menuDensity, setMenuDensity] = React.useState<TableDensity | null>(null);
99
+ // Allow the menu to drive layout when the parent doesn't provide a handler.
100
+ const [menuLayoutMode, setMenuLayoutMode] =
101
+ React.useState<TableLayoutMode | null>(null);
102
+ // Allow the menu to drive grid type when the parent doesn't provide a handler.
103
+ const [menuGridType, setMenuGridType] =
104
+ React.useState<TableGridType | null>(null);
105
+ // Grid-only field visibility state. Kept separate so list mode remains pure TanStack.
106
+ const [menuGridVisibleColumnIds, setMenuGridVisibleColumnIds] =
107
+ React.useState<string[] | null>(null);
108
+ const headerScrollRef = React.useRef<HTMLDivElement | null>(null);
109
+ const bodyLeftScrollRef = React.useRef<HTMLDivElement | null>(null);
110
+ const bodyCenterScrollRef = React.useRef<HTMLDivElement | null>(null);
111
+ const bodyRightScrollRef = React.useRef<HTMLDivElement | null>(null);
112
+ const normalizedLayoutModeOptions = React.useMemo<TableLayoutMode[]>(() => {
113
+ // Keep layout options predictable: valid ids only, stable order, no duplicates.
114
+ const options = layoutModeOptions.filter(
115
+ (option, index, source): option is TableLayoutMode =>
116
+ (option === 'list' || option === 'grid') && source.indexOf(option) === index
117
+ );
118
+ return options.length > 0 ? options : ['list'];
119
+ }, [layoutModeOptions]);
120
+ const normalizedGridTypeOptions = React.useMemo<TableGridType[]>(() => {
121
+ // Keep grid type options predictable: valid ids only, stable order, no duplicates.
122
+ const options = gridTypeOptions.filter(
123
+ (option, index, source): option is TableGridType =>
124
+ (option === 'grid' || option === 'masonry') &&
125
+ source.indexOf(option) === index
126
+ );
127
+ return options.length > 0 ? options : ['grid'];
128
+ }, [gridTypeOptions]);
129
+ // Density is shared by list + grid presentation (row/cell padding, grid spacing, typography).
130
+ // If controlled, always reflect the prop; otherwise use menu selection.
131
+ const resolvedDensity = onDensityChange ? density : menuDensity ?? density;
132
+ // If layout is controlled, always reflect the prop; otherwise use menu selection.
133
+ // Then clamp to allowed layout options so list-only surfaces cannot drift into grid mode.
134
+ const requestedLayoutMode = onLayoutModeChange
135
+ ? layoutMode
136
+ : menuLayoutMode ?? layoutMode;
137
+ // Clamp external/default values to available options so config mistakes fail safely.
138
+ const resolvedLayoutMode = normalizedLayoutModeOptions.includes(requestedLayoutMode)
139
+ ? requestedLayoutMode
140
+ : normalizedLayoutModeOptions[0];
141
+ // Grid type is only relevant in grid layout and follows the same controlled/uncontrolled pattern.
142
+ const requestedGridType = onGridTypeChange ? gridType : menuGridType ?? gridType;
143
+ const resolvedGridType = normalizedGridTypeOptions.includes(requestedGridType)
144
+ ? requestedGridType
145
+ : normalizedGridTypeOptions[0];
146
+ const hasListViewOption = normalizedLayoutModeOptions.includes('list');
147
+ const hasGridLayoutOption = normalizedLayoutModeOptions.includes('grid');
148
+ const hasStandardGridViewOption =
149
+ hasGridLayoutOption && normalizedGridTypeOptions.includes('grid');
150
+ const hasMasonryGridViewOption =
151
+ hasGridLayoutOption && normalizedGridTypeOptions.includes('masonry');
152
+ const availableViewOptionCount =
153
+ Number(hasListViewOption) +
154
+ Number(hasStandardGridViewOption) +
155
+ Number(hasMasonryGridViewOption);
156
+ const hasViewToggleMenu = enableLayoutToggle && availableViewOptionCount > 1;
157
+ // Menu appears when any built-in capability needs it.
158
+ const hasBuiltInMenu = enableColumnControls || hasViewToggleMenu;
159
+ // Compose TanStack options while preserving user overrides.
160
+ const resolvedTableOptions = {
161
+ data,
162
+ columns,
163
+ getRowId,
164
+ // Ensure we always have a core row model unless the caller overrides it.
165
+ getCoreRowModel: tableOptions?.getCoreRowModel ?? getCoreRowModel(),
166
+ // Provide a default sorted row model when sorting is enabled.
167
+ ...(enableSorting && !tableOptions?.getSortedRowModel
168
+ ? { getSortedRowModel: getSortedRowModel() }
169
+ : {}),
170
+ ...tableOptions,
171
+ };
172
+
173
+ const table = useReactTable(resolvedTableOptions);
174
+ const rows = table.getRowModel().rows;
175
+ const paginationState = table.getState().pagination;
176
+ const pageIndex = paginationState?.pageIndex ?? 0;
177
+ const pageSize = paginationState?.pageSize ?? 10;
178
+ const pageCount = table.getPageCount();
179
+ const canPreviousPage = table.getCanPreviousPage();
180
+ const canNextPage = table.getCanNextPage();
181
+
182
+ const resolvedPaginationLabels = {
183
+ pageLabel: 'Page',
184
+ rowsLabel: 'Rows',
185
+ prevLabel: 'Prev',
186
+ nextLabel: 'Next',
187
+ ...paginationLabels,
188
+ };
189
+
190
+ const baseClass = 'solara-table';
191
+ const densityClass = `solara-table--density-${resolvedDensity}`;
192
+ const layoutClass = `solara-table--layout-${resolvedLayoutMode}`;
193
+ const stickyHeaderClass = stickyHeader ? 'solara-table--sticky-header' : '';
194
+
195
+ const transparentStickyClass = transparentSticky
196
+ ? 'solara-table--transparent-sticky'
197
+ : '';
198
+ const transparentBackgroundClass = transparentBackground
199
+ ? 'solara-table--transparent-background'
200
+ : '';
201
+
202
+ const resolvedTableClassName = ['solara-table__table', tableClassName]
203
+ .filter(Boolean)
204
+ .join(' ');
205
+
206
+ const resolvedHeaderClassName = ['solara-table__head', headerClassName]
207
+ .filter(Boolean)
208
+ .join(' ');
209
+
210
+ const resolvedBodyClassName = ['solara-table__body', bodyClassName]
211
+ .filter(Boolean)
212
+ .join(' ');
213
+ const toCssLength = (value: number | string | undefined) =>
214
+ typeof value === 'number' ? `${value}px` : value;
215
+ const setGridStyleVar = (
216
+ style: React.CSSProperties,
217
+ variableName: string,
218
+ value: string | undefined
219
+ ) => {
220
+ if (!value) return;
221
+ // CSS custom properties are string-indexed in React style objects.
222
+ (style as Record<string, string>)[variableName] = value;
223
+ };
224
+
225
+ // Keep array equality checks local and cheap for render-time/state-sync guards.
226
+ const areColumnIdsEqual = (left: string[], right: string[]) => {
227
+ if (left.length !== right.length) return false;
228
+ for (let index = 0; index < left.length; index += 1) {
229
+ if (left[index] !== right[index]) return false;
230
+ }
231
+ return true;
232
+ };
233
+
234
+ const visibleColumns = table.getVisibleLeafColumns();
235
+ const visibleColumnIds = visibleColumns.map((column) => column.id);
236
+ const visibleColumnIdSet = new Set(visibleColumnIds);
237
+ // Signatures let effects depend on value-stable keys instead of array identity.
238
+ const visibleColumnIdsSignature = visibleColumnIds.join('|');
239
+
240
+ // Build grid defaults from currently TanStack-visible columns.
241
+ // This preserves list/grid parity by default while allowing grid-specific hidden defaults.
242
+ const defaultGridVisibleColumnIds = React.useMemo(
243
+ () =>
244
+ visibleColumns
245
+ // `hiddenByDefault` only affects grid overlay defaults.
246
+ // It does not modify TanStack's base visibility state.
247
+ .filter((column) => !gridColumnConfig?.[column.id]?.hiddenByDefault)
248
+ .map((column) => column.id),
249
+ [visibleColumns, gridColumnConfig]
250
+ );
251
+ const defaultGridVisibleColumnIdsSignature =
252
+ defaultGridVisibleColumnIds.join('|');
253
+
254
+ const resolvedGridVisibleColumnIdsRaw = onGridVisibleColumnIdsChange
255
+ ? gridVisibleColumnIds ?? defaultGridVisibleColumnIds
256
+ : menuGridVisibleColumnIds ?? defaultGridVisibleColumnIds;
257
+
258
+ // Keep grid visible ids bounded to columns currently visible in TanStack.
259
+ // Grid visibility is an overlay; TanStack remains the canonical source of actual column presence.
260
+ const resolvedGridVisibleColumnIds = resolvedGridVisibleColumnIdsRaw.filter(
261
+ (columnId) => visibleColumnIdSet.has(columnId)
262
+ );
263
+ const resolvedGridVisibleColumnIdSet = new Set(resolvedGridVisibleColumnIds);
264
+
265
+ React.useEffect(() => {
266
+ if (onGridVisibleColumnIdsChange) return;
267
+ // Preserve user grid field toggles while pruning removed columns and seeding new defaults.
268
+ // This prevents stale ids after visibility/order/schema changes.
269
+ // Relationship note:
270
+ // - TanStack visibility changes can remove ids from the grid overlay.
271
+ // - Newly visible TanStack columns should re-enter overlay defaults when applicable.
272
+ setMenuGridVisibleColumnIds((previous) => {
273
+ if (!previous) return previous;
274
+ const currentVisibleSet = new Set(visibleColumnIds);
275
+ const next = previous.filter((columnId) => currentVisibleSet.has(columnId));
276
+ const nextSet = new Set(next);
277
+ defaultGridVisibleColumnIds.forEach((columnId) => {
278
+ if (!nextSet.has(columnId)) next.push(columnId);
279
+ });
280
+ // Performance/stability guard: avoid state writes when values did not change.
281
+ // This prevents render loops from identity-only array changes.
282
+ return areColumnIdsEqual(previous, next) ? previous : next;
283
+ });
284
+ }, [
285
+ onGridVisibleColumnIdsChange,
286
+ visibleColumnIdsSignature,
287
+ defaultGridVisibleColumnIdsSignature,
288
+ ]);
289
+ const getPinnedSide = (
290
+ column: (typeof visibleColumns)[number]
291
+ ): 'left' | 'right' | false => {
292
+ const pin = column.getIsPinned?.();
293
+ if (pin === 'left' || pin === 'right') return pin;
294
+ return false;
295
+ };
296
+ const leftColumns = visibleColumns.filter(
297
+ (column) => getPinnedSide(column) === 'left'
298
+ );
299
+ const rightColumns = visibleColumns.filter(
300
+ (column) => getPinnedSide(column) === 'right'
301
+ );
302
+ const centerColumns = visibleColumns.filter(
303
+ (column) => !getPinnedSide(column)
304
+ );
305
+ const hasPinnedColumns = leftColumns.length > 0 || rightColumns.length > 0;
306
+
307
+ // Use a split layout when transparency would otherwise reveal content beneath
308
+ // sticky headers or pinned columns (separate header/body or left/center/right panes).
309
+ // This avoids "underlap" without painting a background.
310
+ const useSplitStickyLayout =
311
+ transparentSticky && (stickyHeader || hasPinnedColumns);
312
+ const splitStickyClass = useSplitStickyLayout
313
+ ? 'solara-table--split-sticky'
314
+ : '';
315
+
316
+ const wrapperClassName = [
317
+ baseClass,
318
+ densityClass,
319
+ layoutClass,
320
+ stickyHeaderClass,
321
+ transparentStickyClass,
322
+ transparentBackgroundClass,
323
+ splitStickyClass,
324
+ className,
325
+ ]
326
+ .filter(Boolean)
327
+ .join(' ');
328
+
329
+
330
+ // Built-in action menu (column controls + density + optional layout switching).
331
+ // This is intentionally internal so we can evolve it without changing the public API.
332
+ const renderColumnControls = () => {
333
+ if (!hasBuiltInMenu) return null;
334
+
335
+ // One column control surface, with behavior bound to the active layout.
336
+ // - list: toggle TanStack visibility
337
+ // - grid: toggle grid-visible overlay
338
+ const isGridLayout = resolvedLayoutMode === 'grid';
339
+ const allColumns = table.getAllLeafColumns();
340
+ // Grid-only visibility is a second layer on top of TanStack visibility.
341
+ // In grid mode we only expose columns TanStack currently considers visible.
342
+ const columnsForMenu = isGridLayout ? visibleColumns : allColumns;
343
+ const order = table.getState().columnOrder;
344
+ // If no explicit order is set, derive the natural column order from TanStack.
345
+ const orderedIds =
346
+ order.length > 0 ? order : allColumns.map((column) => column.id);
347
+
348
+ const setGridVisibleColumns = (nextIds: string[]) => {
349
+ // Grid field visibility is intentionally independent from TanStack column visibility
350
+ // so list mode and grid mode can have different defaults, presets, and user toggles.
351
+ // We always clamp to TanStack-visible ids to maintain single source-of-truth boundaries.
352
+ const nextVisibleIds = nextIds.filter((columnId) =>
353
+ visibleColumnIdSet.has(columnId)
354
+ );
355
+ // Guard no-op updates to avoid unnecessary renders/callback cascades.
356
+ if (areColumnIdsEqual(nextVisibleIds, resolvedGridVisibleColumnIds)) return;
357
+ if (onGridVisibleColumnIdsChange) {
358
+ onGridVisibleColumnIdsChange(nextVisibleIds);
359
+ } else {
360
+ setMenuGridVisibleColumnIds(nextVisibleIds);
361
+ }
362
+ };
363
+
364
+ // Move a column one slot left/right while keeping the rest stable.
365
+ const moveColumn = (columnId: string, direction: 'left' | 'right') => {
366
+ const currentIndex = orderedIds.indexOf(columnId);
367
+ if (currentIndex === -1) return;
368
+ const nextIndex = direction === 'left' ? currentIndex - 1 : currentIndex + 1;
369
+ if (nextIndex < 0 || nextIndex >= orderedIds.length) return;
370
+
371
+ const nextOrder = [...orderedIds];
372
+ const [moved] = nextOrder.splice(currentIndex, 1);
373
+ nextOrder.splice(nextIndex, 0, moved);
374
+ // TanStack owns canonical order state; this keeps list rendering + menu aligned.
375
+ table.setColumnOrder(nextOrder);
376
+ };
377
+
378
+ return (
379
+ <MenuDropdown
380
+ align="end"
381
+ trigger={(state) => (
382
+ <button
383
+ type="button"
384
+ aria-label={columnControlsLabel}
385
+ {...state.getTriggerProps({
386
+ className: 'solara-table__header-action-button',
387
+ })}
388
+ >
389
+ <Icon name="gear_filled_16" size={16} aria-hidden />
390
+ </button>
391
+ )}
392
+ >
393
+ <Menu surfaceStyle={resolvedActionMenuSurfaceStyle}>
394
+ {enableColumnControls ? (
395
+ <>
396
+ <MenuSubhead>Columns</MenuSubhead>
397
+ <MenuRow
398
+ data-menu-keep-open="true"
399
+ onClick={() => {
400
+ // In grid layout, "show all" means all currently TanStack-visible columns
401
+ // become visible in grid cards (without mutating TanStack visibility).
402
+ if (isGridLayout) {
403
+ setGridVisibleColumns(visibleColumnIds);
404
+ return;
405
+ }
406
+ // Prefer the TanStack helper when available.
407
+ if (table.toggleAllColumnsVisible) {
408
+ table.toggleAllColumnsVisible(true);
409
+ return;
410
+ }
411
+ allColumns.forEach((column) => {
412
+ if (column.getCanHide?.() === false) return;
413
+ column.toggleVisibility(true);
414
+ });
415
+ }}
416
+ >
417
+ Show all columns
418
+ </MenuRow>
419
+ <MenuRow
420
+ data-menu-keep-open="true"
421
+ onClick={() => {
422
+ // In grid layout, "hide all" collapses the card fields only.
423
+ if (isGridLayout) {
424
+ setGridVisibleColumns([]);
425
+ return;
426
+ }
427
+ // Hide everything that is allowed to be hidden.
428
+ if (table.toggleAllColumnsVisible) {
429
+ table.toggleAllColumnsVisible(false);
430
+ return;
431
+ }
432
+ allColumns.forEach((column) => {
433
+ if (column.getCanHide?.() === false) return;
434
+ column.toggleVisibility(false);
435
+ });
436
+ }}
437
+ >
438
+ Hide all columns
439
+ </MenuRow>
440
+ <MenuSeparator />
441
+ {columnsForMenu.map((column) => {
442
+ // One shared column section: the UI is stable, behavior switches by layout.
443
+ const isVisible = isGridLayout
444
+ ? resolvedGridVisibleColumnIdSet.has(column.id)
445
+ : column.getIsVisible();
446
+ const canHide = isGridLayout
447
+ ? true
448
+ : (column.getCanHide?.() ?? true);
449
+ const isPinned = Boolean(column.getIsPinned?.());
450
+ const columnLabel =
451
+ typeof column.columnDef.header === 'string'
452
+ ? column.columnDef.header
453
+ : column.id;
454
+ const currentIndex = orderedIds.indexOf(column.id);
455
+ // Avoid reordering pinned columns to prevent conflicting UX.
456
+ // Reordering stays list-only because grid does not rely on TanStack column order
457
+ // for card section semantics (media/detail composition controls this instead).
458
+ const canMoveLeft = currentIndex > 0 && !isPinned;
459
+ const canMoveRight =
460
+ currentIndex !== -1 &&
461
+ currentIndex < orderedIds.length - 1 &&
462
+ !isPinned;
463
+
464
+ return (
465
+ <div
466
+ key={column.id}
467
+ className={[
468
+ 'solara-menu-row',
469
+ 'solara-table__menu-row',
470
+ isGridLayout ? 'solara-table__menu-row--simple' : null,
471
+ ]
472
+ .filter(Boolean)
473
+ .join(' ')}
474
+ role="presentation"
475
+ data-menu-keep-open="true"
476
+ >
477
+ <button
478
+ type="button"
479
+ className="solara-table__menu-toggle"
480
+ disabled={!canHide}
481
+ onClick={() => {
482
+ if (!canHide) return;
483
+ if (isGridLayout) {
484
+ const nextSet = new Set(resolvedGridVisibleColumnIds);
485
+ if (isVisible) nextSet.delete(column.id);
486
+ else nextSet.add(column.id);
487
+ setGridVisibleColumns(Array.from(nextSet));
488
+ return;
489
+ }
490
+ column.toggleVisibility(!isVisible);
491
+ }}
492
+ >
493
+ <span className="solara-table__menu-toggle-label">
494
+ {columnLabel}
495
+ </span>
496
+ <span
497
+ className={
498
+ isVisible
499
+ ? 'solara-table__menu-visibility'
500
+ : 'solara-table__menu-visibility solara-table__menu-visibility--hidden'
501
+ }
502
+ aria-hidden
503
+ >
504
+ {isVisible ? 'On' : 'Off'}
505
+ </span>
506
+ </button>
507
+ {!isGridLayout ? (
508
+ <div className="solara-table__menu-row-actions">
509
+ <button
510
+ type="button"
511
+ data-menu-keep-open="true"
512
+ className="solara-table__menu-icon-button"
513
+ aria-label={`Move ${columnLabel} left`}
514
+ disabled={!canMoveLeft}
515
+ onClick={(event) => {
516
+ event.stopPropagation();
517
+ moveColumn(column.id, 'left');
518
+ }}
519
+ >
520
+ <Icon name="arrow_chevron_left_16" size={16} aria-hidden />
521
+ </button>
522
+ <button
523
+ type="button"
524
+ data-menu-keep-open="true"
525
+ className="solara-table__menu-icon-button"
526
+ aria-label={`Move ${columnLabel} right`}
527
+ disabled={!canMoveRight}
528
+ onClick={(event) => {
529
+ event.stopPropagation();
530
+ moveColumn(column.id, 'right');
531
+ }}
532
+ >
533
+ <Icon name="arrow_chevron_right_16" size={16} aria-hidden />
534
+ </button>
535
+ </div>
536
+ ) : null}
537
+ </div>
538
+ );
539
+ })}
540
+ {!isGridLayout ? (
541
+ <>
542
+ <MenuSeparator />
543
+ <MenuRow
544
+ data-menu-keep-open="true"
545
+ // Reset to the natural column order stored in TanStack.
546
+ onClick={() => table.resetColumnOrder?.()}
547
+ >
548
+ Reset column order
549
+ </MenuRow>
550
+ </>
551
+ ) : null}
552
+ </>
553
+ ) : null}
554
+ {enableColumnControls && densityOptions.length > 0 ? (
555
+ <>
556
+ <MenuSeparator />
557
+ <MenuSubhead>Density</MenuSubhead>
558
+ {densityOptions.map((option) => (
559
+ <MenuRow
560
+ key={option}
561
+ data-menu-keep-open="true"
562
+ isActive={resolvedDensity === option}
563
+ onClick={() => {
564
+ if (onDensityChange) {
565
+ onDensityChange(option);
566
+ } else {
567
+ setMenuDensity(option);
568
+ }
569
+ }}
570
+ >
571
+ {option}
572
+ </MenuRow>
573
+ ))}
574
+ </>
575
+ ) : null}
576
+ {hasViewToggleMenu ? (
577
+ <>
578
+ <MenuSeparator />
579
+ <MenuSubhead>View</MenuSubhead>
580
+ {hasListViewOption ? (
581
+ <MenuRow
582
+ key="view-list"
583
+ data-menu-keep-open="true"
584
+ isActive={resolvedLayoutMode === 'list'}
585
+ onClick={() => {
586
+ if (onLayoutModeChange) {
587
+ onLayoutModeChange('list');
588
+ } else {
589
+ setMenuLayoutMode('list');
590
+ }
591
+ }}
592
+ >
593
+ List
594
+ </MenuRow>
595
+ ) : null}
596
+ {hasStandardGridViewOption ? (
597
+ <MenuRow
598
+ key="view-grid-standard"
599
+ data-menu-keep-open="true"
600
+ isActive={
601
+ resolvedLayoutMode === 'grid' && resolvedGridType === 'grid'
602
+ }
603
+ onClick={() => {
604
+ if (onLayoutModeChange) {
605
+ onLayoutModeChange('grid');
606
+ } else {
607
+ setMenuLayoutMode('grid');
608
+ }
609
+ if (onGridTypeChange) {
610
+ onGridTypeChange('grid');
611
+ } else {
612
+ setMenuGridType('grid');
613
+ }
614
+ }}
615
+ >
616
+ Standard grid
617
+ </MenuRow>
618
+ ) : null}
619
+ {hasMasonryGridViewOption ? (
620
+ <MenuRow
621
+ key="view-grid-masonry"
622
+ data-menu-keep-open="true"
623
+ isActive={
624
+ resolvedLayoutMode === 'grid' && resolvedGridType === 'masonry'
625
+ }
626
+ onClick={() => {
627
+ if (onLayoutModeChange) {
628
+ onLayoutModeChange('grid');
629
+ } else {
630
+ setMenuLayoutMode('grid');
631
+ }
632
+ if (onGridTypeChange) {
633
+ onGridTypeChange('masonry');
634
+ } else {
635
+ setMenuGridType('masonry');
636
+ }
637
+ }}
638
+ >
639
+ Masonry grid
640
+ </MenuRow>
641
+ ) : null}
642
+ </>
643
+ ) : null}
644
+ </Menu>
645
+ </MenuDropdown>
646
+ );
647
+ };
648
+
649
+ // Render header groups (supports column grouping and multi-row headers).
650
+ const renderColGroup = (
651
+ columnsToRender: Array<(typeof visibleColumns)[number]>,
652
+ includeActions: boolean
653
+ ) => {
654
+ return (
655
+ <colgroup>
656
+ {columnsToRender.map((column) => {
657
+ const size = column.getSize?.();
658
+ return (
659
+ <col
660
+ key={column.id}
661
+ style={size ? { width: `${size}px` } : undefined}
662
+ />
663
+ );
664
+ })}
665
+ {includeActions ? <col className="solara-table__actions-col" /> : null}
666
+ </colgroup>
667
+ );
668
+ };
669
+
670
+ // Resolve header groups for a pin region; falls back to filtering the full groups.
671
+ // The fallback keeps only headers whose leaf columns are all in the same pin region.
672
+ const getHeaderGroupsForRegion = (region: 'left' | 'center' | 'right') => {
673
+ type HeaderGroups = ReturnType<typeof table.getHeaderGroups>;
674
+ const tableAny = table as unknown as {
675
+ getLeftHeaderGroups?: () => HeaderGroups;
676
+ getCenterHeaderGroups?: () => HeaderGroups;
677
+ getRightHeaderGroups?: () => HeaderGroups;
678
+ };
679
+
680
+ // Prefer TanStack's regional header groups when available.
681
+ const direct =
682
+ region === 'left'
683
+ ? tableAny.getLeftHeaderGroups?.()
684
+ : region === 'right'
685
+ ? tableAny.getRightHeaderGroups?.()
686
+ : tableAny.getCenterHeaderGroups?.();
687
+
688
+ if (direct && direct.length > 0) return direct;
689
+
690
+ // Fallback: filter header groups to a single pin region.
691
+ const headerGroups = table.getHeaderGroups();
692
+ return headerGroups
693
+ .map((group) => {
694
+ const headers = group.headers.filter((header) => {
695
+ const leafHeaders = header.getLeafHeaders
696
+ ? header.getLeafHeaders()
697
+ : [header];
698
+ const regions = new Set(
699
+ leafHeaders.map((leaf) => {
700
+ const pinned = getPinnedSide(leaf.column);
701
+ if (pinned === 'left') return 'left';
702
+ if (pinned === 'right') return 'right';
703
+ return 'center';
704
+ })
705
+ );
706
+ return regions.size === 1 && regions.has(region);
707
+ });
708
+
709
+ return { ...group, headers };
710
+ })
711
+ .filter((group) => group.headers.length > 0);
712
+ };
713
+
714
+ // Resolve visible cells for a pin region; falls back to filtering row cells.
715
+ // This keeps column order consistent with TanStack for each pane.
716
+ const getRowCellsForRegion = (
717
+ row: (typeof rows)[number],
718
+ region: 'left' | 'center' | 'right'
719
+ ) => {
720
+ type RowCells = ReturnType<typeof row.getVisibleCells>;
721
+ const rowAny = row as unknown as {
722
+ getLeftVisibleCells?: () => RowCells;
723
+ getCenterVisibleCells?: () => RowCells;
724
+ getRightVisibleCells?: () => RowCells;
725
+ };
726
+
727
+ // Prefer TanStack's regional cell helpers when available.
728
+ const direct =
729
+ region === 'left'
730
+ ? rowAny.getLeftVisibleCells?.()
731
+ : region === 'right'
732
+ ? rowAny.getRightVisibleCells?.()
733
+ : rowAny.getCenterVisibleCells?.();
734
+
735
+ if (direct && direct.length > 0) return direct;
736
+
737
+ // Fallback: filter cells to a single pin region.
738
+ return row.getVisibleCells().filter((cell) => {
739
+ const pinned = getPinnedSide(cell.column);
740
+ if (region === 'left') return pinned === 'left';
741
+ if (region === 'right') return pinned === 'right';
742
+ return !pinned;
743
+ });
744
+ };
745
+
746
+ const renderHeaderSection = (
747
+ headerGroups: ReturnType<typeof table.getHeaderGroups>,
748
+ includeActions: boolean
749
+ ) => {
750
+ if (!showHeader) return null;
751
+
752
+ const columnControls = hasBuiltInMenu ? renderColumnControls() : null;
753
+ // Allow custom header actions while still keeping the built-in controls.
754
+ const resolvedHeaderActions =
755
+ headerActions && columnControls ? (
756
+ <div className="solara-table__header-actions-stack">
757
+ {headerActions}
758
+ {columnControls}
759
+ </div>
760
+ ) : (
761
+ headerActions ?? columnControls
762
+ );
763
+ // Use a dedicated header cell for actions so it stays aligned with header content.
764
+ const hasHeaderActions = includeActions && Boolean(resolvedHeaderActions);
765
+ const headerRowSpan = headerGroups.length;
766
+
767
+ const resolvedHeaderStyle = transparentBackground ? { background: 'transparent' } : undefined;
768
+
769
+ return (
770
+ <thead className={resolvedHeaderClassName} style={resolvedHeaderStyle}>
771
+ {headerGroups.map((headerGroup, groupIndex) => (
772
+ <tr key={headerGroup.id}>
773
+ {headerGroup.headers.map((header) => {
774
+ if (header.isPlaceholder) return <th key={header.id} />;
775
+
776
+ const headerProps = getHeaderProps?.(header);
777
+ const {
778
+ className: headerPropsClassName,
779
+ style: headerPropsStyle,
780
+ ...restHeaderProps
781
+ } = headerProps ?? {};
782
+ const resolvedHeaderStyle =
783
+ transparentSticky
784
+ ? { ...(headerPropsStyle ?? {}), background: 'transparent', boxShadow: 'none' }
785
+ : headerPropsStyle;
786
+ const headerContent = renderHeaderCell
787
+ ? renderHeaderCell(header)
788
+ : flexRender(header.column.columnDef.header, header.getContext());
789
+ // Sorting state comes from TanStack so we only render controls when supported.
790
+ const canSort = header.column.getCanSort();
791
+ const sortingState = header.column.getIsSorted();
792
+ const sortIndicator =
793
+ sortingState === 'asc'
794
+ ? 'arrow_chevron_up_16'
795
+ : sortingState === 'desc'
796
+ ? 'arrow_chevron_down_16'
797
+ : null;
798
+
799
+ return (
800
+ <th
801
+ key={header.id}
802
+ className={[
803
+ 'solara-table__header-cell',
804
+ headerCellClassName?.(header),
805
+ canSort ? 'solara-table__header-cell--sortable' : null,
806
+ headerPropsClassName,
807
+ ]
808
+ .filter(Boolean)
809
+ .join(' ')}
810
+ style={resolvedHeaderStyle}
811
+ {...restHeaderProps}
812
+ >
813
+ {canSort ? (
814
+ <button
815
+ type="button"
816
+ className="solara-table__sort"
817
+ onClick={header.column.getToggleSortingHandler()}
818
+ >
819
+ <span>{headerContent}</span>
820
+ {sortIndicator ? (
821
+ <span className="solara-table__sort-indicator">
822
+ <Icon name={sortIndicator} size={16} aria-hidden />
823
+ </span>
824
+ ) : null}
825
+ </button>
826
+ ) : (
827
+ headerContent
828
+ )}
829
+ </th>
830
+ );
831
+ })}
832
+ {hasHeaderActions && groupIndex === 0 ? (
833
+ <th
834
+ className="solara-table__header-actions-cell"
835
+ rowSpan={headerRowSpan}
836
+ >
837
+ {resolvedHeaderActions}
838
+ </th>
839
+ ) : null}
840
+ </tr>
841
+ ))}
842
+ </thead>
843
+ );
844
+ };
845
+
846
+ // Default body renderer that maps TanStack row/cell models to <tr>/<td>.
847
+ // We render one body per pane when split layout is active.
848
+ const renderDefaultBodySection = (
849
+ region: 'left' | 'center' | 'right',
850
+ includeActions: boolean
851
+ ) => (
852
+ <tbody className={resolvedBodyClassName}>
853
+ {rows.length === 0 ? (
854
+ region === 'center' ? (
855
+ <tr className="solara-table__empty">
856
+ <td
857
+ colSpan={
858
+ Math.max(1, region === 'center' ? centerColumns.length : 1) +
859
+ (includeActions ? 1 : 0)
860
+ }
861
+ >
862
+ {emptyState ?? 'No results.'}
863
+ </td>
864
+ </tr>
865
+ ) : null
866
+ ) : (
867
+ rows.map((row) => {
868
+ // State flags are read from TanStack once per row and mapped to CSS classes.
869
+ // This keeps highlight behavior centralized (selected, expanded, and sub-row).
870
+ const isSelected = row.getIsSelected?.() ?? false;
871
+ const isExpanded = row.getIsExpanded?.() ?? false;
872
+ const isSubRow = row.depth > 0;
873
+ const isInExpandedGroup = isSubRow && (row.getParentRow?.()?.getIsExpanded?.() ?? false);
874
+ const rowProps = getRowProps?.(row);
875
+ const { className: rowPropsClassName, ...restRowProps } =
876
+ rowProps ?? {};
877
+ const rowCells = getRowCellsForRegion(row, region).map((cell) => {
878
+ const cellProps = getCellProps?.(cell);
879
+ const {
880
+ className: cellPropsClassName,
881
+ style: cellPropsStyle,
882
+ ...restCellProps
883
+ } = cellProps ?? {};
884
+ const resolvedCellStyle =
885
+ transparentSticky
886
+ ? { ...(cellPropsStyle ?? {}), background: 'transparent', boxShadow: 'none' }
887
+ : cellPropsStyle;
888
+ const cellContent = renderCell
889
+ ? renderCell(cell)
890
+ : flexRender(cell.column.columnDef.cell, cell.getContext());
891
+
892
+ return (
893
+ <td
894
+ key={cell.id}
895
+ className={[
896
+ 'solara-table__cell',
897
+ cellClassName?.(cell),
898
+ cellPropsClassName,
899
+ ]
900
+ .filter(Boolean)
901
+ .join(' ')}
902
+ style={resolvedCellStyle}
903
+ {...restCellProps}
904
+ >
905
+ {cellContent}
906
+ </td>
907
+ );
908
+ });
909
+
910
+ if (renderRow && region === 'center' && !hasPinnedColumns) {
911
+ return (
912
+ <React.Fragment key={row.id}>
913
+ {renderRow(row, rowCells)}
914
+ </React.Fragment>
915
+ );
916
+ }
917
+
918
+ return (
919
+ <tr
920
+ key={row.id}
921
+ className={[
922
+ 'solara-table__row',
923
+ isSelected ? 'solara-table__row--selected' : null,
924
+ isExpanded ? 'solara-table__row--expanded' : null,
925
+ isSubRow ? 'solara-table__row--subrow' : null,
926
+ isInExpandedGroup ? 'solara-table__row--in-expanded-group' : null,
927
+ rowClassName?.(row),
928
+ onRowClick ? 'solara-table__row--clickable' : null,
929
+ rowPropsClassName,
930
+ ]
931
+ .filter(Boolean)
932
+ .join(' ')}
933
+ // Expose state to assistive tech and allow selector-based styling hooks.
934
+ aria-selected={isSelected || undefined}
935
+ data-selected={isSelected ? 'true' : undefined}
936
+ data-expanded={isExpanded ? 'true' : undefined}
937
+ onClick={(event) => onRowClick?.(row, event)}
938
+ {...restRowProps}
939
+ >
940
+ {rowCells}
941
+ {/* Keep row alignment when a header actions column is present. */}
942
+ {includeActions ? (
943
+ <td className="solara-table__actions-cell" />
944
+ ) : null}
945
+ </tr>
946
+ );
947
+ })
948
+ )}
949
+ </tbody>
950
+ );
951
+
952
+ const resolveGridCellContent = (
953
+ row: (typeof rows)[number],
954
+ cell: ReturnType<(typeof rows)[number]['getVisibleCells']>[number]
955
+ ) => {
956
+ // Grid can override cell rendering globally or by column id.
957
+ const columnRenderer = gridCellRenderers?.[cell.column.id];
958
+ if (columnRenderer) return columnRenderer(cell, row, table);
959
+ if (renderGridCell) return renderGridCell(cell, row, table);
960
+ if (renderCell) return renderCell(cell);
961
+ return flexRender(cell.column.columnDef.cell, cell.getContext());
962
+ };
963
+
964
+ const buildGridCellsForRow = (row: (typeof rows)[number]) => {
965
+ return row
966
+ .getVisibleCells()
967
+ // Apply grid overlay visibility after TanStack visibility so base table logic always wins.
968
+ .filter((cell) => resolvedGridVisibleColumnIdSet.has(cell.column.id))
969
+ .map((cell) => {
970
+ const columnId = cell.column.id;
971
+ const config = gridColumnConfig?.[columnId];
972
+ // `media` cells render in a dedicated full-width area above detail fields.
973
+ // `detail` cells render in the content section with label/value semantics.
974
+ const variant = config?.variant ?? 'detail';
975
+ const showLabel = config?.showLabel ?? variant !== 'media';
976
+ const headerLabel =
977
+ typeof cell.column.columnDef.header === 'string'
978
+ ? cell.column.columnDef.header
979
+ : columnId;
980
+
981
+ return {
982
+ id: cell.id,
983
+ columnId,
984
+ headerLabel,
985
+ variant,
986
+ showLabel,
987
+ unpadded: Boolean(config?.unpadded),
988
+ // Keep class composition local so custom grid configs can extend existing cell styling.
989
+ className: [cellClassName?.(cell), config?.className]
990
+ .filter(Boolean)
991
+ .join(' '),
992
+ // Content is resolved once here so both default/custom grid row renderers share behavior.
993
+ content: resolveGridCellContent(row, cell),
994
+ };
995
+ });
996
+ };
997
+
998
+ const renderDefaultGridRow = (row: (typeof rows)[number]) => {
999
+ const rowCells = buildGridCellsForRow(row);
1000
+ // Media-first composition keeps image/video hero content full-bleed at the top.
1001
+ const mediaCells = rowCells.filter((cell) => cell.variant === 'media');
1002
+ const detailCells = rowCells.filter((cell) => cell.variant !== 'media');
1003
+ const isSelected = row.getIsSelected?.() ?? false;
1004
+ const isExpanded = row.getIsExpanded?.() ?? false;
1005
+ const isSubRow = row.depth > 0;
1006
+ const isInExpandedGroup =
1007
+ isSubRow && (row.getParentRow?.()?.getIsExpanded?.() ?? false);
1008
+
1009
+ return (
1010
+ <div
1011
+ key={row.id}
1012
+ className={[
1013
+ 'solara-table__grid-row',
1014
+ 'solara-table__row',
1015
+ isSelected ? 'solara-table__row--selected' : null,
1016
+ isExpanded ? 'solara-table__row--expanded' : null,
1017
+ isSubRow ? 'solara-table__row--subrow' : null,
1018
+ isInExpandedGroup ? 'solara-table__row--in-expanded-group' : null,
1019
+ rowClassName?.(row),
1020
+ onRowClick ? 'solara-table__row--clickable' : null,
1021
+ ]
1022
+ .filter(Boolean)
1023
+ .join(' ')}
1024
+ aria-selected={isSelected || undefined}
1025
+ data-selected={isSelected ? 'true' : undefined}
1026
+ data-expanded={isExpanded ? 'true' : undefined}
1027
+ onClick={(event) => onRowClick?.(row, event)}
1028
+ >
1029
+ {mediaCells.map((cell) => (
1030
+ <div
1031
+ key={cell.id}
1032
+ className={[
1033
+ 'solara-table__grid-cell',
1034
+ 'solara-table__grid-cell--media',
1035
+ cell.unpadded ? 'solara-table__grid-cell--unpadded' : null,
1036
+ cell.className,
1037
+ ]
1038
+ .filter(Boolean)
1039
+ .join(' ')}
1040
+ >
1041
+ {cell.showLabel ? (
1042
+ <div className="solara-table__grid-cell-label">{cell.headerLabel}</div>
1043
+ ) : null}
1044
+ <div className="solara-table__grid-cell-value">{cell.content}</div>
1045
+ </div>
1046
+ ))}
1047
+ {detailCells.length > 0 ? (
1048
+ <div className="solara-table__grid-row-details">
1049
+ {detailCells.map((cell) => (
1050
+ <div
1051
+ key={cell.id}
1052
+ className={[
1053
+ 'solara-table__grid-cell',
1054
+ cell.unpadded ? 'solara-table__grid-cell--unpadded' : null,
1055
+ cell.className,
1056
+ ]
1057
+ .filter(Boolean)
1058
+ .join(' ')}
1059
+ >
1060
+ {cell.showLabel ? (
1061
+ <div className="solara-table__grid-cell-label">{cell.headerLabel}</div>
1062
+ ) : null}
1063
+ <div className="solara-table__grid-cell-value">{cell.content}</div>
1064
+ </div>
1065
+ ))}
1066
+ </div>
1067
+ ) : null}
1068
+ </div>
1069
+ );
1070
+ };
1071
+
1072
+ const renderGridLayout = () => {
1073
+ const actionMenu = hasBuiltInMenu ? renderColumnControls() : null;
1074
+ const resolvedHeaderActions =
1075
+ headerActions && actionMenu ? (
1076
+ <div className="solara-table__header-actions-stack">
1077
+ {headerActions}
1078
+ {actionMenu}
1079
+ </div>
1080
+ ) : (
1081
+ headerActions ?? actionMenu
1082
+ );
1083
+ const hasGridActions = Boolean(resolvedHeaderActions);
1084
+ const gridMinWidth = toCssLength(gridMinColumnWidth) ?? '260px';
1085
+ const gridTypeClass: TableGridType =
1086
+ resolvedGridType === 'masonry' ? 'masonry' : 'grid';
1087
+ const gridContainerStyle: React.CSSProperties = {};
1088
+ const gridGap = toCssLength(gridSpacing?.gap);
1089
+ // Explicit per-axis gaps fall back to `gap` to keep config concise when symmetry is fine.
1090
+ const gridColumnGap = toCssLength(gridSpacing?.columnGap ?? gridSpacing?.gap);
1091
+ const gridRowGap = toCssLength(gridSpacing?.rowGap ?? gridSpacing?.gap);
1092
+ const gridDetailsGap = toCssLength(gridSpacing?.detailsGap);
1093
+ const gridDetailsPadding = toCssLength(gridSpacing?.detailsPadding);
1094
+
1095
+ // Keep all spacing overrides token-based so density and custom spacing compose predictably.
1096
+ setGridStyleVar(gridContainerStyle, '--solara-table-grid-gap', gridGap);
1097
+ setGridStyleVar(gridContainerStyle, '--solara-table-grid-column-gap', gridColumnGap);
1098
+ setGridStyleVar(gridContainerStyle, '--solara-table-grid-row-gap', gridRowGap);
1099
+ setGridStyleVar(gridContainerStyle, '--solara-table-grid-details-gap', gridDetailsGap);
1100
+ setGridStyleVar(
1101
+ gridContainerStyle,
1102
+ '--solara-table-grid-details-padding',
1103
+ gridDetailsPadding
1104
+ );
1105
+
1106
+ if (gridTypeClass === 'masonry') {
1107
+ // Masonry uses CSS columns while card internals keep the same media/detail structure.
1108
+ // We set only column width here and let browser columns stack variable-height cards naturally.
1109
+ gridContainerStyle.columnWidth = gridMinWidth;
1110
+ } else {
1111
+ // Standard grid keeps predictable row/column tracks with auto-fit cards.
1112
+ gridContainerStyle.gridTemplateColumns = `repeat(auto-fit, minmax(${gridMinWidth}, 1fr))`;
1113
+ }
1114
+
1115
+ return (
1116
+ <div
1117
+ className={wrapperClassName}
1118
+ {...wrapperProps}
1119
+ style={{ ...(wrapperProps?.style ?? {}) }}
1120
+ >
1121
+ {hasGridActions ? (
1122
+ <div className="solara-table__grid-toolbar">{resolvedHeaderActions}</div>
1123
+ ) : null}
1124
+ {rows.length === 0 ? (
1125
+ <div className="solara-table__grid-empty">{emptyState ?? 'No results.'}</div>
1126
+ ) : (
1127
+ <div
1128
+ className={[
1129
+ 'solara-table__grid',
1130
+ gridTypeClass === 'masonry'
1131
+ ? 'solara-table__grid--masonry'
1132
+ : 'solara-table__grid--standard',
1133
+ ]
1134
+ .filter(Boolean)
1135
+ .join(' ')}
1136
+ style={gridContainerStyle}
1137
+ >
1138
+ {rows.map((row) => {
1139
+ if (!renderGridRow) return renderDefaultGridRow(row);
1140
+ // Custom grid rows receive the filtered grid-visible cells in order.
1141
+ const cells = buildGridCellsForRow(row).map((cell) => cell.content);
1142
+ return (
1143
+ <React.Fragment key={row.id}>
1144
+ {renderGridRow(row, cells, table)}
1145
+ </React.Fragment>
1146
+ );
1147
+ })}
1148
+ </div>
1149
+ )}
1150
+ </div>
1151
+ );
1152
+ };
1153
+
1154
+ const sharedTableStyle = transparentBackground
1155
+ ? { ...(tableProps?.style ?? {}), background: 'transparent' }
1156
+ : tableProps?.style;
1157
+
1158
+ const stripWidthStyles = (
1159
+ style: React.CSSProperties | undefined
1160
+ ): React.CSSProperties | undefined => {
1161
+ if (!style) return style;
1162
+ const { width, minWidth, maxWidth, ...rest } = style;
1163
+ return rest;
1164
+ };
1165
+
1166
+ const centerTableProps = tableProps;
1167
+ const sideTableProps =
1168
+ tableProps && tableProps.style
1169
+ ? { ...tableProps, style: stripWidthStyles(tableProps.style) }
1170
+ : tableProps;
1171
+
1172
+ // Layout branch point: grid mode bypasses table markup and renders card composition.
1173
+ // TanStack row modeling is still shared; only presentation changes.
1174
+ if (resolvedLayoutMode === 'grid') {
1175
+ return renderGridLayout();
1176
+ }
1177
+
1178
+ if (useSplitStickyLayout) {
1179
+ const headerGroupsLeft = getHeaderGroupsForRegion('left');
1180
+ const headerGroupsCenter = getHeaderGroupsForRegion('center');
1181
+ const headerGroupsRight = getHeaderGroupsForRegion('right');
1182
+ const hasHeaderActions = Boolean(hasBuiltInMenu || headerActions);
1183
+ const actionsInRight = hasHeaderActions && rightColumns.length > 0;
1184
+ const actionsInCenter = hasHeaderActions && !actionsInRight;
1185
+ const useCustomBody = Boolean(renderBody) && !hasPinnedColumns;
1186
+ // Custom list body is disabled with pinned split panes to avoid pane desync/structure mismatch.
1187
+
1188
+ const wrapperStyle = { ...(wrapperProps?.style ?? {}) } as React.CSSProperties;
1189
+ const bodyWrapperStyle: React.CSSProperties = {
1190
+ ...(wrapperProps?.style ?? {}),
1191
+ overflow: 'auto',
1192
+ };
1193
+ const bodySideStyle: React.CSSProperties = {
1194
+ ...bodyWrapperStyle,
1195
+ overflowY: 'auto',
1196
+ overflowX: 'hidden',
1197
+ };
1198
+ const { style: _ignoredStyle, onScroll, ...restWrapperProps } =
1199
+ wrapperProps ?? {};
1200
+
1201
+ // Keep header + side panes in sync with the center scroll container.
1202
+ // Center pane owns horizontal scroll; side panes mirror vertical scroll.
1203
+ const handleBodyScroll: React.UIEventHandler<HTMLDivElement> = (event) => {
1204
+ const target = event.currentTarget;
1205
+ if (headerScrollRef.current) {
1206
+ headerScrollRef.current.scrollLeft = target.scrollLeft;
1207
+ }
1208
+ if (bodyLeftScrollRef.current) {
1209
+ bodyLeftScrollRef.current.scrollTop = target.scrollTop;
1210
+ }
1211
+ if (bodyRightScrollRef.current) {
1212
+ bodyRightScrollRef.current.scrollTop = target.scrollTop;
1213
+ }
1214
+ onScroll?.(event);
1215
+ };
1216
+
1217
+ return (
1218
+ <div
1219
+ className={wrapperClassName}
1220
+ {...restWrapperProps}
1221
+ style={wrapperStyle}
1222
+ >
1223
+ <div className="solara-table__split-header">
1224
+ {leftColumns.length > 0 ? (
1225
+ <table
1226
+ className={`${resolvedTableClassName} solara-table__pane solara-table__pane--left`}
1227
+ style={stripWidthStyles(sharedTableStyle)}
1228
+ {...sideTableProps}
1229
+ >
1230
+ {renderColGroup(leftColumns, false)}
1231
+ {renderHeaderSection(headerGroupsLeft, false)}
1232
+ </table>
1233
+ ) : null}
1234
+ <div className="solara-table__header-scroll" ref={headerScrollRef}>
1235
+ <table
1236
+ className={`${resolvedTableClassName} solara-table__pane solara-table__pane--center`}
1237
+ style={sharedTableStyle}
1238
+ {...centerTableProps}
1239
+ >
1240
+ {renderColGroup(centerColumns, actionsInCenter)}
1241
+ {renderHeaderSection(headerGroupsCenter, actionsInCenter)}
1242
+ </table>
1243
+ </div>
1244
+ {rightColumns.length > 0 ? (
1245
+ <table
1246
+ className={`${resolvedTableClassName} solara-table__pane solara-table__pane--right`}
1247
+ style={stripWidthStyles(sharedTableStyle)}
1248
+ {...sideTableProps}
1249
+ >
1250
+ {renderColGroup(rightColumns, actionsInRight)}
1251
+ {renderHeaderSection(headerGroupsRight, actionsInRight)}
1252
+ </table>
1253
+ ) : null}
1254
+ </div>
1255
+ <div className="solara-table__split-body">
1256
+ {leftColumns.length > 0 ? (
1257
+ <div
1258
+ className="solara-table__body-side"
1259
+ ref={bodyLeftScrollRef}
1260
+ style={bodySideStyle}
1261
+ >
1262
+ <table
1263
+ className={`${resolvedTableClassName} solara-table__pane solara-table__pane--left`}
1264
+ style={stripWidthStyles(sharedTableStyle)}
1265
+ {...sideTableProps}
1266
+ >
1267
+ {renderColGroup(leftColumns, false)}
1268
+ {renderDefaultBodySection('left', false)}
1269
+ </table>
1270
+ </div>
1271
+ ) : null}
1272
+ <div
1273
+ className="solara-table__body-center"
1274
+ ref={bodyCenterScrollRef}
1275
+ style={bodyWrapperStyle}
1276
+ onScroll={handleBodyScroll}
1277
+ >
1278
+ <table
1279
+ className={`${resolvedTableClassName} solara-table__pane solara-table__pane--center`}
1280
+ style={sharedTableStyle}
1281
+ {...centerTableProps}
1282
+ >
1283
+ {renderColGroup(centerColumns, actionsInCenter)}
1284
+ {useCustomBody
1285
+ ? renderBody?.(table, rows)
1286
+ : renderDefaultBodySection('center', actionsInCenter)}
1287
+ </table>
1288
+ </div>
1289
+ {rightColumns.length > 0 ? (
1290
+ <div
1291
+ className="solara-table__body-side"
1292
+ ref={bodyRightScrollRef}
1293
+ style={bodySideStyle}
1294
+ >
1295
+ <table
1296
+ className={`${resolvedTableClassName} solara-table__pane solara-table__pane--right`}
1297
+ style={stripWidthStyles(sharedTableStyle)}
1298
+ {...sideTableProps}
1299
+ >
1300
+ {renderColGroup(rightColumns, actionsInRight)}
1301
+ {renderDefaultBodySection('right', actionsInRight)}
1302
+ </table>
1303
+ </div>
1304
+ ) : null}
1305
+ </div>
1306
+ </div>
1307
+ );
1308
+ }
1309
+
1310
+ return (
1311
+ <div
1312
+ className={wrapperClassName}
1313
+ {...wrapperProps}
1314
+ style={{
1315
+ ...(wrapperProps?.style ?? {}),
1316
+ }}
1317
+ >
1318
+ <table
1319
+ className={resolvedTableClassName}
1320
+ style={sharedTableStyle}
1321
+ {...tableProps}
1322
+ >
1323
+ {renderHeaderSection(table.getHeaderGroups(), hasBuiltInMenu || Boolean(headerActions))}
1324
+ {renderBody
1325
+ ? renderBody(table, rows)
1326
+ : renderDefaultBodySection('center', hasBuiltInMenu || Boolean(headerActions))}
1327
+ </table>
1328
+ {/* Pagination footer is layout-agnostic TanStack state surfaced with Solara controls. */}
1329
+ {showPagination ? (
1330
+ <div
1331
+ className="solara-table__pagination"
1332
+ style={{
1333
+ display: 'flex',
1334
+ alignItems: 'center',
1335
+ justifyContent: 'space-between',
1336
+ marginTop: '12px',
1337
+ gap: '12px',
1338
+ fontSize: '12px',
1339
+ }}
1340
+ >
1341
+ <div>
1342
+ <span style={{ marginRight: '6px' }}>{resolvedPaginationLabels.pageLabel}</span>
1343
+ <strong>
1344
+ {pageIndex + 1}
1345
+ {pageCount > 0 && pageCount !== -1 ? ` of ${pageCount}` : ''}
1346
+ </strong>
1347
+ </div>
1348
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
1349
+ <span>{resolvedPaginationLabels.rowsLabel}</span>
1350
+ <Dropdown
1351
+ size="small"
1352
+ variant="ghost"
1353
+ value={String(pageSize)}
1354
+ style={{ minWidth: '72px' }}
1355
+ onChange={(event) => table.setPageSize(Number(event.target.value))}
1356
+ options={paginationPageSizes.map((size) => ({
1357
+ label: String(size),
1358
+ value: String(size),
1359
+ }))}
1360
+ />
1361
+ <Button
1362
+ label={resolvedPaginationLabels.prevLabel}
1363
+ variant="ghost"
1364
+ size="small"
1365
+ onClick={() => table.previousPage()}
1366
+ disabled={!canPreviousPage}
1367
+ />
1368
+ <Button
1369
+ label={resolvedPaginationLabels.nextLabel}
1370
+ variant="ghost"
1371
+ size="small"
1372
+ onClick={() => table.nextPage()}
1373
+ disabled={!canNextPage}
1374
+ />
1375
+ </div>
1376
+ </div>
1377
+ ) : null}
1378
+ </div>
1379
+ );
1380
+ }