mirror of
https://github.com/docmost/docmost.git
synced 2026-07-09 23:44:31 +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 { 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user