@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/docs.md +617 -0
- package/examples/index.tsx +1 -0
- package/examples.tsx +1177 -0
- package/package.json +32 -0
- package/src/Table.scss +531 -0
- package/src/Table.tsx +1380 -0
- package/src/Table.types.ts +158 -0
- package/src/index.ts +2 -0
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
|
+
}
|