Skip to content

Commit 82d0ff9

Browse files
Refactor and fix data-table variable height rows (#667)
1 parent 0448f6b commit 82d0ff9

File tree

18 files changed

+372
-511
lines changed

18 files changed

+372
-511
lines changed

src/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"@replit/codemirror-indentation-markers": "^6.5.3",
6666
"@tanstack/react-query": "^5.90.21",
6767
"@tanstack/react-table": "^8.21.3",
68-
"@tanstack/react-virtual": "3.13.12",
68+
"@tanstack/react-virtual": "^3.13.21",
6969
"@uiw/react-codemirror": "^4.25.4",
7070
"@use-gesture/react": "^10.3.1",
7171
"@xterm/addon-fit": "^0.11.0",

src/ui/pnpm-lock.yaml

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ui/src/components/data-table/data-table.tsx

Lines changed: 39 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ export interface DataTableProps<TData, TSectionMeta = unknown> {
8585
headerClassName?: string;
8686
/** Extra classes applied to the <thead> element (e.g. "file-browser-thead" for a shadow-based bottom divider) */
8787
theadClassName?: string;
88-
rowClassName?: string | ((item: TData) => string);
88+
rowClassName?: string | ((item: TData, index: number) => string);
8989
sectionClassName?: string | ((section: Section<TData, TSectionMeta>) => string);
90+
/** Whether a given row should show hover/pointer styles. Defaults to !!onRowClick for all rows. */
91+
isRowInteractive?: (row: TData) => boolean;
9092
columnSizeConfigs?: readonly ColumnSizeConfig[];
9193
columnSizingPreferences?: ColumnSizingPreferences;
9294
onColumnSizingPreferenceChange?: (columnId: string, preference: ColumnSizingPreference) => void;
@@ -128,7 +130,6 @@ function DataTableInner<TData, TSectionMeta = unknown>({
128130
onLoadMore,
129131
isFetchingNextPage,
130132
totalCount,
131-
// Row heights use canonical constants from @/lib/config
132133
rowHeight = TABLE_ROW_HEIGHTS.NORMAL,
133134
sectionHeight = TABLE_ROW_HEIGHTS.SECTION,
134135
className,
@@ -151,6 +152,7 @@ function DataTableInner<TData, TSectionMeta = unknown>({
151152
suspendResize,
152153
resizeCompleteEvent,
153154
registerLayoutStableCallback,
155+
isRowInteractive,
154156
}: DataTableProps<TData, TSectionMeta>) {
155157
const scrollRef = useRef<HTMLDivElement>(null);
156158
const tableElementRef = useRef<HTMLTableElement>(null);
@@ -178,7 +180,6 @@ function DataTableInner<TData, TSectionMeta = unknown>({
178180
return columns
179181
.map((c) => {
180182
if (typeof c.id === "string") return c.id;
181-
// AccessorKeyColumnDef has accessorKey property
182183
if ("accessorKey" in c && c.accessorKey) return String(c.accessorKey);
183184
return "";
184185
})
@@ -199,38 +200,20 @@ function DataTableInner<TData, TSectionMeta = unknown>({
199200

200201
const visibleColumnCount = visibleColumnIds.length;
201202

202-
const columnMinSizes = useMemo(() => {
203-
const sizes: Record<string, number> = {};
204-
for (const col of columns) {
205-
const colId = col.id ?? ("accessorKey" in col && col.accessorKey ? String(col.accessorKey) : "");
206-
if (colId && col.minSize != null) {
207-
sizes[colId] = col.minSize;
208-
}
209-
}
210-
return sizes;
211-
}, [columns]);
203+
const { columnMinSizes, columnInitialSizes, columnResizability } = useMemo(() => {
204+
const mins: Record<string, number> = {};
205+
const initials: Record<string, number> = {};
206+
const resizability: Record<string, boolean> = {};
212207

213-
const columnInitialSizes = useMemo(() => {
214-
const sizes: Record<string, number> = {};
215208
for (const col of columns) {
216209
const colId = col.id ?? ("accessorKey" in col && col.accessorKey ? String(col.accessorKey) : "");
217-
if (colId && col.size != null) {
218-
sizes[colId] = col.size;
219-
}
210+
if (!colId) continue;
211+
if (col.minSize != null) mins[colId] = col.minSize;
212+
if (col.size != null) initials[colId] = col.size;
213+
resizability[colId] = col.enableResizing !== false;
220214
}
221-
return sizes;
222-
}, [columns]);
223215

224-
const columnResizability = useMemo(() => {
225-
const resizability: Record<string, boolean> = {};
226-
for (const col of columns) {
227-
const colId = col.id ?? ("accessorKey" in col && col.accessorKey ? String(col.accessorKey) : "");
228-
if (colId) {
229-
// enableResizing defaults to true if not specified
230-
resizability[colId] = col.enableResizing !== false;
231-
}
232-
}
233-
return resizability;
216+
return { columnMinSizes: mins, columnInitialSizes: initials, columnResizability: resizability };
234217
}, [columns]);
235218

236219
const showSkeleton = isLoading && allItems.length === 0;
@@ -252,19 +235,34 @@ function DataTableInner<TData, TSectionMeta = unknown>({
252235
registerLayoutStableCallback,
253236
});
254237

255-
// Track previous data length to detect empty → populated transitions
238+
// Toggle `is-scrolling` class to suppress row-position transitions during scroll.
239+
// Removed 150ms after the last scroll event so expand/collapse animations work.
240+
useEffect(() => {
241+
const scrollEl = scrollRef.current;
242+
if (!scrollEl) return;
243+
let timeoutId: ReturnType<typeof setTimeout>;
244+
const onScroll = () => {
245+
scrollEl.classList.add("is-scrolling");
246+
clearTimeout(timeoutId);
247+
timeoutId = setTimeout(() => scrollEl.classList.remove("is-scrolling"), 150);
248+
};
249+
scrollEl.addEventListener("scroll", onScroll, { passive: true });
250+
return () => {
251+
scrollEl.removeEventListener("scroll", onScroll);
252+
clearTimeout(timeoutId);
253+
};
254+
}, []);
255+
256+
// Recalculate column widths when data first arrives (empty -> populated)
256257
const prevDataLength = usePrevious(allItems.length);
257-
258-
// When transitioning from empty (0 items) to populated (>0 items),
259-
// trigger column recalculation to ensure columns fill available space
258+
const { recalculate } = columnSizingHook;
260259
useEffect(() => {
261260
if (prevDataLength === 0 && allItems.length > 0) {
262-
// Use RAF to ensure DOM has updated before recalculating
263261
requestAnimationFrame(() => {
264-
columnSizingHook.recalculate();
262+
recalculate();
265263
});
266264
}
267-
}, [prevDataLength, allItems.length, columnSizingHook]);
265+
}, [prevDataLength, allItems.length, recalculate]);
268266

269267
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Table returns unstable functions by design
270268
const table = useReactTable({
@@ -316,7 +314,6 @@ function DataTableInner<TData, TSectionMeta = unknown>({
316314
useVirtualizedTable<TData, TSectionMeta>({
317315
items: sections ? undefined : data,
318316
sections,
319-
getRowId,
320317
scrollRef,
321318
rowHeight,
322319
sectionHeight,
@@ -369,7 +366,9 @@ function DataTableInner<TData, TSectionMeta = unknown>({
369366

370367
const rowNavigation = useRowNavigation({
371368
rowCount: virtualItemCount,
372-
visibleRowCount: Math.floor(600 / rowHeight),
369+
visibleRowCount: scrollRef.current
370+
? Math.max(1, Math.floor(scrollRef.current.clientHeight / rowHeight))
371+
: Math.floor(600 / rowHeight),
373372
onRowActivate: useCallback(
374373
(virtualIndex: number) => {
375374
const item = getItem(virtualIndex);
@@ -511,7 +510,6 @@ function DataTableInner<TData, TSectionMeta = unknown>({
511510

512511
const colIndex = headerIndex + 1;
513512

514-
// Get custom header className from column meta (dependency injection)
515513
const headerClassName = header.column.columnDef.meta?.headerClassName;
516514

517515
if (isFixed) {
@@ -577,6 +575,7 @@ function DataTableInner<TData, TSectionMeta = unknown>({
577575
onRowKeyDown={rowNavigation.handleRowKeyDown}
578576
measureElement={measureElement}
579577
compact={compact}
578+
isRowInteractive={isRowInteractive}
580579
/>
581580
</table>
582581

@@ -635,5 +634,4 @@ function DataTableInner<TData, TSectionMeta = unknown>({
635634
);
636635
}
637636

638-
// Memoize with generic type preservation
639637
export const DataTable = memo(DataTableInner) as typeof DataTableInner;

src/ui/src/components/data-table/hooks/use-column-sizing.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ export function useColumnSizing({
269269

270270
// Track pending idle callback for cleanup
271271
const pendingIdleCallbackRef = useRef<number | ReturnType<typeof setTimeout> | null>(null);
272+
// Track which scheduler was used so cleanup uses the correct cancel API
273+
const useRicRef = useRef(typeof requestIdleCallback !== "undefined");
272274

273275
// Remeasure when a column becomes NO_TRUNCATE
274276
useEffect(() => {
@@ -305,7 +307,11 @@ export function useColumnSizing({
305307
}
306308
};
307309

308-
if (typeof requestIdleCallback !== "undefined") {
310+
// Capture the scheduler flag at effect setup time so the cleanup function
311+
// can use the same value (lint: ref values may change by cleanup time).
312+
const useRic = useRicRef.current;
313+
314+
if (useRic) {
309315
pendingIdleCallbackRef.current = requestIdleCallback(measureNewColumns, { timeout: 500 });
310316
} else {
311317
// Safari fallback: Use RAF to ensure DOM measurement happens at optimal time
@@ -317,13 +323,11 @@ export function useColumnSizing({
317323

318324
return () => {
319325
if (pendingIdleCallbackRef.current !== null) {
320-
if (typeof cancelIdleCallback !== "undefined" && typeof pendingIdleCallbackRef.current === "number") {
321-
cancelIdleCallback(pendingIdleCallbackRef.current);
322-
} else if (typeof cancelAnimationFrame !== "undefined") {
326+
if (useRic) {
327+
cancelIdleCallback(pendingIdleCallbackRef.current as number);
328+
} else {
323329
// Safari fallback uses RAF, so cancel with cancelAnimationFrame
324330
cancelAnimationFrame(pendingIdleCallbackRef.current as number);
325-
} else {
326-
clearTimeout(pendingIdleCallbackRef.current as ReturnType<typeof setTimeout>);
327331
}
328332
pendingIdleCallbackRef.current = null;
329333
}

0 commit comments

Comments
 (0)