feat(base): make column headers sticky in standalone and inline contexts

Split the unified .gridWrapper into a sticky band (containing column
headers, plus banner + toolbar in inline) and a body grid that owns
horizontal scroll. The band's vertical sticky anchor is automatic CSS:
the table's scrollport in standalone, the page in inline. A small
useHorizontalScrollSync hook mirrors body scrollLeft onto the header
and turns wheel-on-header into pan-on-body.
This commit is contained in:
Philipinho
2026-04-27 14:21:03 +01:00
parent 1cfd0fb2c4
commit cd8d1e0ed8
8 changed files with 277 additions and 172 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Text, Stack } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useAtom } from "jotai";
@@ -151,6 +151,8 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
clearSelection();
}, [pageId, activeView?.id, clearSelection]);
const scrollportRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => {
const flat = flattenRows(rowsData);
// When a sort is active, the server returns rows in the requested
@@ -314,61 +316,76 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
if (!base) return null;
// When embedded inline in a doc page, the parent <NodeViewWrapper>
// exposes --embed-extend-l / --embed-extend-r (positive px). We
// pull both edges outward via negative margin so the scroll viewport
// grows toward AppShell.Main's edges. Initial visual alignment with
// page text is preserved by --embed-grid-pad-left, applied to the
// .grid in grid.module.css — that padding makes the first cell sit
// at page-content-left on load while still letting the user pan
// left into the extended viewport. Toolbar is unchanged.
const gridExtendStyle = embedded
? ({
marginLeft: "calc(-1 * var(--embed-extend-l, 0px))",
marginRight: "calc(-1 * var(--embed-extend-r, 0px))",
} as const)
: undefined;
const banner = (
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
);
const toolbar = (
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={handleAddView}
onPersistViewConfig={persistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
/>
);
const grid = (
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
pageId={pageId}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
scrollElement={embedded ? window : scrollportRef.current}
stickyBandPrelude={
embedded ? (
<>
{banner}
{toolbar}
</>
) : null
}
/>
);
if (embedded) {
// Inline: banner + toolbar live inside the StickyBand (passed via
// stickyBandPrelude). The page is the vertical scroll container.
return grid;
}
// Standalone: banner + toolbar sit above the .tableScrollport, which
// is the vertical scroll container. StickyBand inside contains only
// the column-header row.
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: embedded ? "auto" : "100%",
height: "100%",
}}
>
<BaseViewDraftBanner
isDirty={isDirty}
canSave={canSave}
onReset={resetDraft}
onSave={handleSaveDraft}
saving={updateViewMutation.isPending}
/>
<BaseToolbar
base={base}
activeView={effectiveView}
views={views}
table={table}
onViewChange={handleViewChange}
onAddView={handleAddView}
onPersistViewConfig={persistViewConfig}
onDraftSortsChange={handleDraftSortsChange}
onDraftFiltersChange={handleDraftFiltersChange}
/>
<div style={gridExtendStyle}>
<GridContainer
table={table}
properties={base.properties}
onCellUpdate={handleCellUpdate}
onAddRow={handleAddRow}
pageId={pageId}
onColumnReorder={handleColumnReorder}
onResizeEnd={handleResizeEnd}
onRowReorder={handleRowReorder}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
/>
{banner}
{toolbar}
<div className={classes.tableScrollport} ref={scrollportRef}>
{grid}
</div>
</div>
);
@@ -135,9 +135,11 @@ export const GridCell = memo(function GridCell({
return (
<div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
}}
style={
isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: undefined
}
onDoubleClick={handleDoubleClick}
>
<CellComponent
@@ -1,6 +1,11 @@
import { useRef, useMemo, useCallback, useEffect } from "react";
import { useRef, useMemo, useCallback, useEffect, useState, useLayoutEffect } from "react";
import { Table } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
observeWindowOffset,
observeWindowRect,
useVirtualizer,
windowScroll,
} from "@tanstack/react-virtual";
import { useAtom } from "jotai";
import {
DndContext,
@@ -23,6 +28,7 @@ import { useGridKeyboardNav } from "@/features/base/hooks/use-grid-keyboard-nav"
import { useRowDrag } from "@/features/base/hooks/use-row-drag";
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows";
import { useHorizontalScrollSync } from "@/features/base/hooks/use-horizontal-scroll-sync";
import { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button";
@@ -32,6 +38,16 @@ import classes from "@/features/base/styles/grid.module.css";
const ROW_HEIGHT = 36;
const OVERSCAN = 10;
// Hoisted to module scope so we don't allocate a fresh options object
// every GridContainer render — the function refs from virtual-core are
// stable, only the wrapper object identity matters for downstream
// memoization inside useVirtualizer.
const WINDOW_SCROLL_OPTIONS = {
observeElementRect: observeWindowRect as never,
observeElementOffset: observeWindowOffset as never,
scrollToFn: windowScroll as never,
} as const;
type GridContainerProps = {
table: Table<IBaseRow>;
properties: IBaseProperty[];
@@ -44,6 +60,19 @@ type GridContainerProps = {
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onFetchNextPage?: () => void;
/**
* What the virtualizer measures and what the StickyBand sticks to.
* Standalone passes a ref into the .tableScrollport wrapper; inline
* passes `window` since the page itself is the scroll container.
*/
scrollElement: HTMLElement | Window | null;
/**
* Rendered above the column-header row inside the StickyBand. In
* inline mode BaseTable injects banner + toolbar here so they stick
* alongside the headers; in standalone this is null (banner +
* toolbar render outside the scrollport).
*/
stickyBandPrelude?: React.ReactNode;
};
export function GridContainer({
@@ -58,8 +87,12 @@ export function GridContainer({
hasNextPage,
isFetchingNextPage,
onFetchNextPage,
scrollElement,
stickyBandPrelude,
}: GridContainerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
useHorizontalScrollSync(bodyRef, headerRef);
const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows;
@@ -100,14 +133,55 @@ export function GridContainer({
table,
editingCell,
setEditingCell,
containerRef: scrollRef,
containerRef: bodyRef,
});
// When the scroll container is the window (inline embed mode),
// useVirtualizer's default Element-mode observers read scrollTop /
// scrollLeft — properties Window doesn't have. Swap in the Window-
// mode observers so the virtualizer reads scrollY / scrollX instead.
// The Element-narrowed type signature is satisfied by an upcast on
// getScrollElement: virtual-core's runtime accepts Window when the
// observers do.
const isWindowScroll =
typeof window !== "undefined" && scrollElement === window;
const windowScrollOptions = isWindowScroll ? WINDOW_SCROLL_OPTIONS : {};
// Window-mode virtualizer reads window.scrollY as offset, but rows
// are positioned within .bodyGrid which sits at some non-zero Y in
// the document (below banner/toolbar/upstream page content). Pass
// scrollMargin = bodyGrid's document-relative top so the virtualizer
// indexes correctly. Re-measure on resize via ResizeObserver — the
// embed extension logic in BaseEmbedView already triggers layout
// changes that we need to react to.
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
if (!isWindowScroll) return;
const el = bodyRef.current;
if (!el) return;
const update = () => {
const rect = el.getBoundingClientRect();
setScrollMargin(rect.top + window.scrollY);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
// Outer page reflows (sidebar collapse, viewport resize) move the
// embed without resizing it — listen to window resize too.
window.addEventListener("resize", update);
return () => {
ro.disconnect();
window.removeEventListener("resize", update);
};
}, [isWindowScroll]);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollRef.current,
getScrollElement: () => scrollElement as Element | null,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN,
scrollMargin,
...windowScrollOptions,
});
const virtualItems = virtualizer.getVirtualItems();
@@ -133,7 +207,7 @@ export function GridContainer({
}, [rows.length]);
useEffect(() => {
const el = scrollRef.current;
const el = bodyRef.current;
if (!el || !pageId) return;
const handler = (e: KeyboardEvent) => {
if (editingCell) return;
@@ -164,8 +238,18 @@ export function GridContainer({
const totalHeight = virtualizer.getTotalSize();
// virtual-core bakes `scrollMargin` into both `start`/`end` and
// `getTotalSize()`. We render padding spacers inside .bodyGrid to
// position rows in the grid flow, so paddingTop must be relative to
// .bodyGrid's own top — subtract scrollMargin out of items[0].start
// (which would otherwise push the first row down by the full embed
// offset, leaving a giant blank gap above the data). totalHeight
// already includes scrollMargin, so paddingBottom needs no
// adjustment.
const paddingTop =
virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0;
virtualItems.length > 0
? Math.max(0, (virtualItems[0]?.start ?? 0) - scrollMargin)
: 0;
const paddingBottom =
virtualItems.length > 0
? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 0)
@@ -196,8 +280,8 @@ export function GridContainer({
// Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollRef.current?.scrollTo({
left: scrollRef.current.scrollWidth,
bodyRef.current?.scrollTo({
left: bodyRef.current.scrollWidth,
behavior: "smooth",
});
});
@@ -236,35 +320,41 @@ export function GridContainer({
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<div
className={classes.gridWrapper}
ref={scrollRef}
tabIndex={0}
>
<div
className={classes.grid}
style={{ gridTemplateColumns }}
role="grid"
>
<SortableContext
items={sortableColumnIds}
strategy={horizontalListSortingStrategy}
<div role="grid">
<div className={classes.stickyBand}>
{stickyBandPrelude}
<div
className={classes.headerGrid}
ref={headerRef}
style={{ gridTemplateColumns }}
role="row"
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
</SortableContext>
<SortableContext
items={sortableColumnIds}
strategy={horizontalListSortingStrategy}
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
</SortableContext>
</div>
</div>
<div
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
style={{ gridTemplateColumns }}
role="rowgroup"
>
{paddingTop > 0 && (
<div style={{ height: paddingTop, gridColumn: "1 / -1" }} />
)}
{virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
@@ -292,13 +382,12 @@ export function GridContainer({
/>
);
})}
{paddingBottom > 0 && (
<div style={{ height: paddingBottom, gridColumn: "1 / -1" }} />
)}
<AddRowButton onClick={handleAddRow} />
{pageId && <SelectionActionBar pageId={pageId} />}
</div>
<AddRowButton onClick={handleAddRow} />
{pageId && <SelectionActionBar pageId={pageId} />}
</div>
</DndContext>
);
@@ -145,7 +145,9 @@ export const GridHeaderCell = memo(function GridHeaderCell({
ref={combinedRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned ? { left: pinOffset } : {}),
...(isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: {}),
...(isRowNumber ? {} : { cursor: "pointer" }),
...sortableStyle,
}}
@@ -44,7 +44,11 @@ export const RowNumberCell = memo(function RowNumberCell({
return (
<div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`}
style={isPinned ? { left: pinOffset } : undefined}
style={
isPinned
? ({ "--pin-offset": `${pinOffset ?? 0}px` } as React.CSSProperties)
: undefined
}
>
<div className={classes.rowNumberCellInner}>
<span