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 { Text, Stack } from "@mantine/core";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@@ -151,6 +151,8 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
clearSelection(); clearSelection();
}, [pageId, activeView?.id, clearSelection]); }, [pageId, activeView?.id, clearSelection]);
const scrollportRef = useRef<HTMLDivElement>(null);
const rows = useMemo(() => { const rows = useMemo(() => {
const flat = flattenRows(rowsData); const flat = flattenRows(rowsData);
// When a sort is active, the server returns rows in the requested // 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; if (!base) return null;
// When embedded inline in a doc page, the parent <NodeViewWrapper> const banner = (
// exposes --embed-extend-l / --embed-extend-r (positive px). We <BaseViewDraftBanner
// pull both edges outward via negative margin so the scroll viewport isDirty={isDirty}
// grows toward AppShell.Main's edges. Initial visual alignment with canSave={canSave}
// page text is preserved by --embed-grid-pad-left, applied to the onReset={resetDraft}
// .grid in grid.module.css — that padding makes the first cell sit onSave={handleSaveDraft}
// at page-content-left on load while still letting the user pan saving={updateViewMutation.isPending}
// 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 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 ( return (
<div <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: embedded ? "auto" : "100%", height: "100%",
}} }}
> >
<BaseViewDraftBanner {banner}
isDirty={isDirty} {toolbar}
canSave={canSave} <div className={classes.tableScrollport} ref={scrollportRef}>
onReset={resetDraft} {grid}
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}
/>
</div> </div>
</div> </div>
); );
@@ -135,9 +135,11 @@ export const GridCell = memo(function GridCell({
return ( return (
<div <div
className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`} className={`${classes.cell} ${isPinned ? classes.cellPinned : ""} ${isEditing ? classes.cellEditing : ""} ${property.isPrimary ? classes.primaryCell : ""}`}
style={{ style={
...(isPinned ? { left: pinOffset } : {}), isPinned
}} ? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: undefined
}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
> >
<CellComponent <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 { 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 { useAtom } from "jotai";
import { import {
DndContext, 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 { useRowDrag } from "@/features/base/hooks/use-row-drag";
import { useRowSelection } from "@/features/base/hooks/use-row-selection"; import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import { useDeleteSelectedRows } from "@/features/base/hooks/use-delete-selected-rows"; 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 { GridHeader } from "./grid-header";
import { GridRow } from "./grid-row"; import { GridRow } from "./grid-row";
import { AddRowButton } from "./add-row-button"; import { AddRowButton } from "./add-row-button";
@@ -32,6 +38,16 @@ import classes from "@/features/base/styles/grid.module.css";
const ROW_HEIGHT = 36; const ROW_HEIGHT = 36;
const OVERSCAN = 10; 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 = { type GridContainerProps = {
table: Table<IBaseRow>; table: Table<IBaseRow>;
properties: IBaseProperty[]; properties: IBaseProperty[];
@@ -44,6 +60,19 @@ type GridContainerProps = {
hasNextPage?: boolean; hasNextPage?: boolean;
isFetchingNextPage?: boolean; isFetchingNextPage?: boolean;
onFetchNextPage?: () => void; 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({ export function GridContainer({
@@ -58,8 +87,12 @@ export function GridContainer({
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
onFetchNextPage, onFetchNextPage,
scrollElement,
stickyBandPrelude,
}: GridContainerProps) { }: GridContainerProps) {
const scrollRef = useRef<HTMLDivElement>(null); const headerRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
useHorizontalScrollSync(bodyRef, headerRef);
const lastTriggeredRowsLenRef = useRef(0); const lastTriggeredRowsLenRef = useRef(0);
const rows = table.getRowModel().rows; const rows = table.getRowModel().rows;
@@ -100,14 +133,55 @@ export function GridContainer({
table, table,
editingCell, editingCell,
setEditingCell, 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({ const virtualizer = useVirtualizer({
count: rows.length, count: rows.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollElement as Element | null,
estimateSize: () => ROW_HEIGHT, estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN, overscan: OVERSCAN,
scrollMargin,
...windowScrollOptions,
}); });
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();
@@ -133,7 +207,7 @@ export function GridContainer({
}, [rows.length]); }, [rows.length]);
useEffect(() => { useEffect(() => {
const el = scrollRef.current; const el = bodyRef.current;
if (!el || !pageId) return; if (!el || !pageId) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (editingCell) return; if (editingCell) return;
@@ -164,8 +238,18 @@ export function GridContainer({
const totalHeight = virtualizer.getTotalSize(); 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 = const paddingTop =
virtualItems.length > 0 ? virtualItems[0]?.start ?? 0 : 0; virtualItems.length > 0
? Math.max(0, (virtualItems[0]?.start ?? 0) - scrollMargin)
: 0;
const paddingBottom = const paddingBottom =
virtualItems.length > 0 virtualItems.length > 0
? totalHeight - (virtualItems[virtualItems.length - 1]?.end ?? 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 // Wait for React to re-render with the new column, then scroll to it
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollRef.current?.scrollTo({ bodyRef.current?.scrollTo({
left: scrollRef.current.scrollWidth, left: bodyRef.current.scrollWidth,
behavior: "smooth", behavior: "smooth",
}); });
}); });
@@ -236,35 +320,41 @@ export function GridContainer({
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
modifiers={modifiers} modifiers={modifiers}
> >
<div <div role="grid">
className={classes.gridWrapper} <div className={classes.stickyBand}>
ref={scrollRef} {stickyBandPrelude}
tabIndex={0} <div
> className={classes.headerGrid}
<div ref={headerRef}
className={classes.grid} style={{ gridTemplateColumns }}
style={{ gridTemplateColumns }} role="row"
role="grid"
>
<SortableContext
items={sortableColumnIds}
strategy={horizontalListSortingStrategy}
> >
<GridHeader <SortableContext
table={table} items={sortableColumnIds}
pageId={pageId} strategy={horizontalListSortingStrategy}
columnOrder={table.getState().columnOrder} >
columnVisibility={table.getState().columnVisibility} <GridHeader
properties={properties} table={table}
loadedRowIds={rowIds} pageId={pageId}
onPropertyCreated={handlePropertyCreated} columnOrder={table.getState().columnOrder}
/> columnVisibility={table.getState().columnVisibility}
</SortableContext> properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
</SortableContext>
</div>
</div>
<div
className={classes.bodyGrid}
ref={bodyRef}
tabIndex={0}
style={{ gridTemplateColumns }}
role="rowgroup"
>
{paddingTop > 0 && ( {paddingTop > 0 && (
<div style={{ height: paddingTop, gridColumn: "1 / -1" }} /> <div style={{ height: paddingTop, gridColumn: "1 / -1" }} />
)} )}
{virtualItems.map((virtualRow) => { {virtualItems.map((virtualRow) => {
const row = rows[virtualRow.index]; const row = rows[virtualRow.index];
if (!row) return null; if (!row) return null;
@@ -292,13 +382,12 @@ export function GridContainer({
/> />
); );
})} })}
{paddingBottom > 0 && ( {paddingBottom > 0 && (
<div style={{ height: paddingBottom, gridColumn: "1 / -1" }} /> <div style={{ height: paddingBottom, gridColumn: "1 / -1" }} />
)} )}
<AddRowButton onClick={handleAddRow} />
{pageId && <SelectionActionBar pageId={pageId} />}
</div> </div>
<AddRowButton onClick={handleAddRow} />
{pageId && <SelectionActionBar pageId={pageId} />}
</div> </div>
</DndContext> </DndContext>
); );
@@ -145,7 +145,9 @@ export const GridHeaderCell = memo(function GridHeaderCell({
ref={combinedRef} ref={combinedRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`} className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{ style={{
...(isPinned ? { left: pinOffset } : {}), ...(isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: {}),
...(isRowNumber ? {} : { cursor: "pointer" }), ...(isRowNumber ? {} : { cursor: "pointer" }),
...sortableStyle, ...sortableStyle,
}} }}
@@ -44,7 +44,11 @@ export const RowNumberCell = memo(function RowNumberCell({
return ( return (
<div <div
className={`${classes.cell} ${classes.rowNumberCell} ${isPinned ? classes.cellPinned : ""}`} 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}> <div className={classes.rowNumberCellInner}>
<span <span
@@ -42,11 +42,16 @@ export function useHorizontalScrollSync<
// scrollLeft. Convert vertical wheel ticks into horizontal pan. // scrollLeft. Convert vertical wheel ticks into horizontal pan.
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
if (e.deltaY === 0) return; if (e.deltaY === 0) return;
// Suppress the browser's default vertical scroll on the page
// (or the standalone scrollport) — we're consuming this wheel
// event as a horizontal pan, not a vertical scroll. Requires a
// non-passive listener (configured below).
e.preventDefault();
body.scrollLeft += e.deltaY; body.scrollLeft += e.deltaY;
}; };
body.addEventListener("scroll", onBodyScroll, { passive: true }); body.addEventListener("scroll", onBodyScroll, { passive: true });
header.addEventListener("wheel", onHeaderWheel, { passive: true }); header.addEventListener("wheel", onHeaderWheel, { passive: false });
// Initial sync — covers the case where body is already scrolled // Initial sync — covers the case where body is already scrolled
// when the hook mounts (e.g. after a route change). // when the hook mounts (e.g. after a route change).
@@ -24,14 +24,18 @@
* .tableScrollport in standalone, to the page in inline. The * .tableScrollport in standalone, to the page in inline. The
* --sticky-band-top var is set on inline embed wrappers to clear * --sticky-band-top var is set on inline embed wrappers to clear
* the fixed PageHeader; standalone leaves it unset (resolves to 0 * the fixed PageHeader; standalone leaves it unset (resolves to 0
* relative to the scrollport). */ * relative to the scrollport).
*
* Background uses --mantine-color-body (the doc bg) rather than the
* gray-0 token. The headers themselves carry their own gray bg via
* .headerCell, so the band looks like: doc-color strip behind
* banner+toolbar (matches the surrounding doc in inline mode), gray
* row at the headers. The band still needs its own bg so rows don't
* show through the 1-px gaps between header cells when scrolling. */
position: sticky; position: sticky;
top: var(--sticky-band-top, 0); top: var(--sticky-band-top, 0);
z-index: 10; z-index: 10;
background-color: light-dark( background-color: var(--mantine-color-body);
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
border-bottom: 1px solid border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
} }
@@ -44,6 +48,13 @@
* by useHorizontalScrollSync to mirror the body. Inline embeds extend * by useHorizontalScrollSync to mirror the body. Inline embeds extend
* the visible width into the AppShell margins via --embed-extend-*; * the visible width into the AppShell margins via --embed-extend-*;
* standalone leaves those vars unset (calc resolves to 0). */ * standalone leaves those vars unset (calc resolves to 0). */
/* No `min-width: max-content`: this element IS the horizontal
* scrollport (overflow-x: hidden, scrollLeft mirrored via JS), so we
* want its own width to follow the parent. If `min-width: max-content`
* were set here, the grid box itself would grow to fit all tracks
* and propagate width up to AppShell, triggering page-level
* horizontal scroll. Tracks wider than the box are handled by the
* grid's own overflow-x. */
display: grid; display: grid;
overflow-x: hidden; overflow-x: hidden;
margin-left: calc(-1 * var(--embed-extend-l, 0px)); margin-left: calc(-1 * var(--embed-extend-l, 0px));
@@ -56,74 +67,29 @@
/* Same shape as .headerGrid, but overflow-x: auto so the horizontal /* Same shape as .headerGrid, but overflow-x: auto so the horizontal
* scrollbar lives here. Vertical scroll is owned by .tableScrollport * scrollbar lives here. Vertical scroll is owned by .tableScrollport
* (standalone) or the page (inline) — there's no internal vertical * (standalone) or the page (inline) — there's no internal vertical
* scroll on .bodyGrid in either mode. */ * scroll on .bodyGrid in either mode. Same `min-width` argument
* applies as in .headerGrid: leave it unset so this element's box
* matches its parent and tracks-overflow is contained by the grid's
* own overflow-x: auto. */
display: grid; display: grid;
overflow-x: auto; overflow-x: auto;
margin-left: calc(-1 * var(--embed-extend-l, 0px)); margin-left: calc(-1 * var(--embed-extend-l, 0px));
margin-right: calc(-1 * var(--embed-extend-r, 0px)); margin-right: calc(-1 * var(--embed-extend-r, 0px));
padding-left: var(--embed-grid-pad-left, 0); padding-left: var(--embed-grid-pad-left, 0);
padding-right: var(--embed-grid-pad-right, 0); padding-right: var(--embed-grid-pad-right, 0);
/* Match the existing .gridWrapper bottom-padding so the AddRowButton /* Bottom-padding so the AddRowButton keeps clear of the horizontal
* keeps clear of the horizontal scrollbar. */ * scrollbar that lives on this element. */
padding-bottom: 6px; padding-bottom: 6px;
} }
.gridWrapper {
position: relative;
overflow: auto;
overflow-anchor: none;
flex: 1;
min-height: 0;
padding-left: 6px;
/* Reserve space below the AddRowButton so the horizontal scrollbar
* doesn't overlap it. */
padding-bottom: 6px;
}
.grid {
display: grid;
min-width: max-content;
/* Outer border + radius is the panel-style framing for the standalone
* full-page base. Inline embeds override these vars to drop the outer
* frame so the table reads as part of the document — cell-level
* borders below still provide the gridline separators. */
border: var(
--grid-outer-border,
1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))
);
border-radius: var(--grid-outer-radius, var(--mantine-radius-sm));
/* When the embed wrapper extends the scroll viewport leftward, this
* padding pushes the first cell back to page-content alignment so
* the table looks aligned on load. The padded area is part of the
* scrollable region — the user can pan left into it. Standalone
* full-page bases never set the var, so it's a no-op there. */
padding-left: var(--embed-grid-pad-left, 0);
/* Symmetric right-side padding lets the user scroll past the last
* column into empty space, so wide tables don't end abruptly at
* the viewport edge. */
padding-right: var(--embed-grid-pad-right, 0);
}
.headerRow { .headerRow {
/* `display: contents` removes the wrapper from layout while keeping the /* `display: contents` removes the wrapper from layout while keeping the
* `role="row"` for accessibility. Header cells become direct grid items * `role="row"` for accessibility. Header cells become direct grid
* of `.grid`, so their containing block is the full-height table — a * items of `.headerGrid`, so they pick up its column tracks. */
* prerequisite for `position: sticky` on `.headerCell` to actually
* travel the length of the scroll. With a subgrid wrapper here, sticky
* was constrained to the 34px header row and scrolled away. */
display: contents; display: contents;
} }
.headerCell { .headerCell {
/* Sticky to the top of the gridWrapper scroll viewport so the column
* header row stays visible while the user scrolls rows underneath.
* z-index 2 lifts it above non-pinned body cells (default 0); the
* pinned variant below bumps to 3 so the corner cell stays above
* pinned body cells (z-index 1) at the top-left intersection. */
position: sticky;
top: 0;
z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -136,8 +102,6 @@
var(--mantine-color-gray-0), var(--mantine-color-gray-0),
var(--mantine-color-dark-6) var(--mantine-color-dark-6)
); );
border-bottom: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-right: 1px solid border-right: 1px solid
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
user-select: none; user-select: none;
@@ -152,11 +116,28 @@
} }
.headerCellPinned { .headerCellPinned {
/* Sticks both vertically (inherited top: 0) and horizontally (left /* Pinned header cells stick within .headerGrid's scrollport. The
* offset set inline by tanstack-table's column-pinning), so the row- * `left` value is the sticky anchor relative to the scrollport's
* number / primary-property column stays visible at the top-left * content edge.
* corner regardless of scroll axis. */ *
z-index: 3; * Match Notion's freeze behavior: at scrollLeft=0, the pinned column
* sits at its natural position (the page-content edge); as the user
* scrolls horizontally, the column scrolls along with the rest of
* the grid; once it reaches the AppShell-margin edge (the scrollport
* padding edge) it stops and stays there while non-pinned columns
* keep scrolling past behind it.
*
* To make the column travel that --embed-grid-pad-left distance
* before sticking, we anchor at `-padding-left` from the natural
* sticky position. In standalone the var is unset → calc collapses
* to `var(--pin-offset, 0px)` and the column sticks immediately at
* its track position, which is what standalone wants since there's
* no extension to travel through. --pin-offset is set inline by
* GridHeaderCell from TanStack's column.getStart("left"), giving
* each pinned column its accumulated-width offset. */
position: sticky;
left: calc(-1 * var(--embed-grid-pad-left, 0px) + var(--pin-offset, 0px));
z-index: 2;
background-color: light-dark( background-color: light-dark(
var(--mantine-color-gray-0), var(--mantine-color-gray-0),
var(--mantine-color-dark-6) var(--mantine-color-dark-6)
@@ -259,7 +240,11 @@
} }
.cellPinned { .cellPinned {
/* See .headerCellPinned for the rationale on the negative pad-left
* — both grids share the same anchor calculation so header and body
* cells freeze in lockstep. */
position: sticky; position: sticky;
left: calc(-1 * var(--embed-grid-pad-left, 0px) + var(--pin-offset, 0px));
z-index: 1; z-index: 1;
background-color: light-dark( background-color: light-dark(
var(--mantine-color-white), var(--mantine-color-white),
@@ -316,12 +301,17 @@
} }
.addRowButton { .addRowButton {
/* Inline-flex + width:max-content so the button only takes the space /* AddRowButton is a child of .bodyGrid (which is a CSS grid). Without
* it needs and stays anchored to the page-content edge during the * `grid-column: 1 / -1` it would auto-place into track 1 of a new
* horizontal scroll, instead of riding along with the grid as a * grid row, leaving phantom empty cells in the remaining tracks of
* full-row item. --embed-grid-pad-left is the leftward extension * that row. Spanning all tracks gives it a single full-row grid area
* distance in embed mode (sticks at page-content-left), 0 in * while inline-flex + width:max-content + sticky-left keeps the
* standalone (sticks at gridWrapper's natural left). */ * visible button shrunk to its content and anchored to the page-
* content edge during horizontal scroll. --embed-grid-pad-left is
* the leftward extension distance in embed mode (sticks at page-
* content-left), 0 in standalone (sticks at .bodyGrid's natural
* left). */
grid-column: 1 / -1;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -353,12 +343,6 @@
} }
.addColumnButton { .addColumnButton {
/* Sits at the trailing edge of the header row — match the sticky-top
* behaviour of `.headerCell` so it doesn't drift away when the grid
* scrolls vertically. */
position: sticky;
top: 0;
z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -32,10 +32,12 @@ function applyExtension(wrapper: HTMLDivElement) {
// column into empty space — same behaviour as Notion, gives the // column into empty space — same behaviour as Notion, gives the
// table breathing room on the right when scrolled fully right. // table breathing room on the right when scrolled fully right.
wrapper.style.setProperty("--embed-grid-pad-right", `${extendRight}px`); wrapper.style.setProperty("--embed-grid-pad-right", `${extendRight}px`);
// Drop the standalone "panel" frame: inline databases read as part // Inline sticky band clears the fixed PageHeader. Standalone leaves
// of the document, with only cell separators as gridlines. // the var unset (resolves to the rule default of 0).
wrapper.style.setProperty("--grid-outer-border", "none"); wrapper.style.setProperty(
wrapper.style.setProperty("--grid-outer-radius", "0"); "--sticky-band-top",
"var(--page-header-height)",
);
} }
export function BaseEmbedView({ node }: NodeViewProps) { export function BaseEmbedView({ node }: NodeViewProps) {