mirror of
https://github.com/docmost/docmost.git
synced 2026-07-09 16:54:29 +10:00
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:
@@ -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
|
||||
|
||||
@@ -42,11 +42,16 @@ export function useHorizontalScrollSync<
|
||||
// scrollLeft. Convert vertical wheel ticks into horizontal pan.
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) 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.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
|
||||
// when the hook mounts (e.g. after a route change).
|
||||
|
||||
@@ -24,14 +24,18 @@
|
||||
* .tableScrollport in standalone, to the page in inline. The
|
||||
* --sticky-band-top var is set on inline embed wrappers to clear
|
||||
* 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;
|
||||
top: var(--sticky-band-top, 0);
|
||||
z-index: 10;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
background-color: var(--mantine-color-body);
|
||||
border-bottom: 1px solid
|
||||
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
|
||||
* the visible width into the AppShell margins via --embed-extend-*;
|
||||
* 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;
|
||||
overflow-x: hidden;
|
||||
margin-left: calc(-1 * var(--embed-extend-l, 0px));
|
||||
@@ -56,74 +67,29 @@
|
||||
/* Same shape as .headerGrid, but overflow-x: auto so the horizontal
|
||||
* scrollbar lives here. Vertical scroll is owned by .tableScrollport
|
||||
* (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;
|
||||
overflow-x: auto;
|
||||
margin-left: calc(-1 * var(--embed-extend-l, 0px));
|
||||
margin-right: calc(-1 * var(--embed-extend-r, 0px));
|
||||
padding-left: var(--embed-grid-pad-left, 0);
|
||||
padding-right: var(--embed-grid-pad-right, 0);
|
||||
/* Match the existing .gridWrapper bottom-padding so the AddRowButton
|
||||
* keeps clear of the horizontal scrollbar. */
|
||||
/* Bottom-padding so the AddRowButton keeps clear of the horizontal
|
||||
* scrollbar that lives on this element. */
|
||||
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 {
|
||||
/* `display: contents` removes the wrapper from layout while keeping the
|
||||
* `role="row"` for accessibility. Header cells become direct grid items
|
||||
* of `.grid`, so their containing block is the full-height table — a
|
||||
* 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. */
|
||||
* `role="row"` for accessibility. Header cells become direct grid
|
||||
* items of `.headerGrid`, so they pick up its column tracks. */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -136,8 +102,6 @@
|
||||
var(--mantine-color-gray-0),
|
||||
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
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
user-select: none;
|
||||
@@ -152,11 +116,28 @@
|
||||
}
|
||||
|
||||
.headerCellPinned {
|
||||
/* Sticks both vertically (inherited top: 0) and horizontally (left
|
||||
* offset set inline by tanstack-table's column-pinning), so the row-
|
||||
* number / primary-property column stays visible at the top-left
|
||||
* corner regardless of scroll axis. */
|
||||
z-index: 3;
|
||||
/* Pinned header cells stick within .headerGrid's scrollport. The
|
||||
* `left` value is the sticky anchor relative to the scrollport's
|
||||
* content edge.
|
||||
*
|
||||
* 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(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
@@ -259,7 +240,11 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
left: calc(-1 * var(--embed-grid-pad-left, 0px) + var(--pin-offset, 0px));
|
||||
z-index: 1;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-white),
|
||||
@@ -316,12 +301,17 @@
|
||||
}
|
||||
|
||||
.addRowButton {
|
||||
/* Inline-flex + width:max-content so the button only takes the space
|
||||
* it needs and stays anchored to the page-content edge during the
|
||||
* horizontal scroll, instead of riding along with the grid as a
|
||||
* full-row item. --embed-grid-pad-left is the leftward extension
|
||||
* distance in embed mode (sticks at page-content-left), 0 in
|
||||
* standalone (sticks at gridWrapper's natural left). */
|
||||
/* AddRowButton is a child of .bodyGrid (which is a CSS grid). Without
|
||||
* `grid-column: 1 / -1` it would auto-place into track 1 of a new
|
||||
* grid row, leaving phantom empty cells in the remaining tracks of
|
||||
* that row. Spanning all tracks gives it a single full-row grid area
|
||||
* while inline-flex + width:max-content + sticky-left keeps the
|
||||
* 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;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -353,12 +343,6 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -32,10 +32,12 @@ function applyExtension(wrapper: HTMLDivElement) {
|
||||
// column into empty space — same behaviour as Notion, gives the
|
||||
// table breathing room on the right when scrolled fully right.
|
||||
wrapper.style.setProperty("--embed-grid-pad-right", `${extendRight}px`);
|
||||
// Drop the standalone "panel" frame: inline databases read as part
|
||||
// of the document, with only cell separators as gridlines.
|
||||
wrapper.style.setProperty("--grid-outer-border", "none");
|
||||
wrapper.style.setProperty("--grid-outer-radius", "0");
|
||||
// Inline sticky band clears the fixed PageHeader. Standalone leaves
|
||||
// the var unset (resolves to the rule default of 0).
|
||||
wrapper.style.setProperty(
|
||||
"--sticky-band-top",
|
||||
"var(--page-header-height)",
|
||||
);
|
||||
}
|
||||
|
||||
export function BaseEmbedView({ node }: NodeViewProps) {
|
||||
|
||||
Reference in New Issue
Block a user