feat(base): use negative margin to extend inline grid past parent

The previous approach (position: relative + explicit width +
inset-inline-start) didn't physically grow the box in some flex
contexts, so the grid stayed at the parent's width. Switch to
negative margin-left / margin-right on the grid wrapper only —
with width: auto, the rendered width becomes parent + |margin|,
extending the box past the parent without any positioning hacks.

The toolbar keeps its natural parent-constrained width (no extension)
so it stays aligned with the page text above. Two CSS vars,
--embed-extend-l / --embed-extend-r, are computed on mount + on
ResizeObserver from the wrapper and the closest wider ancestor.
This commit is contained in:
Philipinho
2026-04-27 03:51:21 +01:00
parent 8132b171f4
commit d73ac010af
2 changed files with 68 additions and 77 deletions
@@ -314,27 +314,16 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
if (!base) return null; if (!base) return null;
// When the table is embedded inline in a doc page, the parent // When embedded inline in a doc page, the parent <NodeViewWrapper>
// <NodeViewWrapper> measures the available area and exposes // exposes --embed-extend-l / --embed-extend-r (positive px values).
// --embed-width / --embed-shift / --embed-pad. We extend the // We pull the grid's left/right edges outward via negative margin —
// toolbar and grid sections out to the full available width via // box-model: width: auto becomes parent_width + extend, so the box
// those vars, then re-pad the inner content so it visually aligns // physically grows past its parent's bounds. The toolbar keeps the
// with page text. Sticky inset-inline-start keeps the toolbar // parent-constrained width so it stays in line with the page text.
// pinned to the page-content edge during horizontal scroll. const gridExtendStyle = embedded
const extendStyle = embedded
? ({ ? ({
position: "relative", marginLeft: "calc(-1 * var(--embed-extend-l, 0px))",
width: "var(--embed-width, 100%)", marginRight: "calc(-1 * var(--embed-extend-r, 0px))",
insetInlineStart: "var(--embed-shift, 0px)",
paddingInline: "var(--embed-pad, 0px)",
} as const)
: undefined;
const stickyToolbarStyle = embedded
? ({
position: "sticky",
insetInlineStart: 0,
zIndex: 4,
} as const) } as const)
: undefined; : undefined;
@@ -346,29 +335,25 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
height: embedded ? "auto" : "100%", height: embedded ? "auto" : "100%",
}} }}
> >
<div style={extendStyle}> <BaseViewDraftBanner
<BaseViewDraftBanner isDirty={isDirty}
isDirty={isDirty} canSave={canSave}
canSave={canSave} onReset={resetDraft}
onReset={resetDraft} onSave={handleSaveDraft}
onSave={handleSaveDraft} saving={updateViewMutation.isPending}
saving={updateViewMutation.isPending} />
/> <BaseToolbar
<div style={stickyToolbarStyle}> base={base}
<BaseToolbar activeView={effectiveView}
base={base} views={views}
activeView={effectiveView} table={table}
views={views} onViewChange={handleViewChange}
table={table} onAddView={handleAddView}
onViewChange={handleViewChange} onPersistViewConfig={persistViewConfig}
onAddView={handleAddView} onDraftSortsChange={handleDraftSortsChange}
onPersistViewConfig={persistViewConfig} onDraftFiltersChange={handleDraftFiltersChange}
onDraftSortsChange={handleDraftSortsChange} />
onDraftFiltersChange={handleDraftFiltersChange} <div style={gridExtendStyle}>
/>
</div>
</div>
<div style={extendStyle}>
<GridContainer <GridContainer
table={table} table={table}
properties={base.properties} properties={base.properties}
@@ -4,43 +4,40 @@ import { useEffect, useRef } from "react";
import { BaseTable } from "@/features/base/components/base-table"; import { BaseTable } from "@/features/base/components/base-table";
import { useBaseQuery } from "@/features/base/queries/base-query"; import { useBaseQuery } from "@/features/base/queries/base-query";
const SIDE_GUTTER = 8; const RIGHT_GUTTER = 16;
const LEFT_GUTTER = 8;
// Walk up from `el` to find the closest ancestor that's meaningfully // Measure how far we can extend the grid past the wrapper's natural
// wider than `el` itself. That ancestor is the available drawing area // (parent-constrained) bounds, and write the values as CSS vars on the
// our embed should expand into (e.g. AppShell.Main when the page sits // wrapper so the descendant grid can consume them via margin.
// inside a 900px Mantine Container). Returns null if no such ancestor function applyExtension(wrapper: HTMLDivElement) {
// exists — the embed then renders without extension. const rect = wrapper.getBoundingClientRect();
function findWiderAncestor(el: HTMLElement): HTMLElement | null { if (rect.width === 0) return;
const baseWidth = el.getBoundingClientRect().width;
let cur: HTMLElement | null = el.parentElement; const extendRight = Math.max(
0,
window.innerWidth - rect.right - RIGHT_GUTTER,
);
// Find the leftmost the grid can reach: walk up the ancestor chain
// for the closest element wider than the wrapper. That ancestor's
// left edge (plus a small gutter) is our left target. This handles
// the sidebar-collapsed case naturally — the wider ancestor is
// AppShell.Main, whose left edge moves when the sidebar toggles.
let targetLeft = rect.left;
let cur: HTMLElement | null = wrapper.parentElement;
while (cur && cur !== document.body) { while (cur && cur !== document.body) {
const w = cur.getBoundingClientRect().width; const r = cur.getBoundingClientRect();
if (w > baseWidth + 32) return cur; if (r.width > rect.width + 32) {
targetLeft = r.left + LEFT_GUTTER;
break;
}
cur = cur.parentElement; cur = cur.parentElement;
} }
return null; const extendLeft = Math.max(0, rect.left - targetLeft);
}
function applyExtension(wrapper: HTMLDivElement) { wrapper.style.setProperty("--embed-extend-r", `${extendRight}px`);
const wrapperRect = wrapper.getBoundingClientRect(); wrapper.style.setProperty("--embed-extend-l", `${extendLeft}px`);
if (wrapperRect.width === 0) return;
const wider = findWiderAncestor(wrapper);
if (!wider) {
wrapper.style.setProperty("--embed-shift", "0px");
wrapper.style.setProperty("--embed-width", "100%");
wrapper.style.setProperty("--embed-pad", "0px");
return;
}
const widerRect = wider.getBoundingClientRect();
const targetLeft = widerRect.left + SIDE_GUTTER;
const targetWidth = widerRect.width - SIDE_GUTTER * 2;
const shift = targetLeft - wrapperRect.left;
wrapper.style.setProperty("--embed-shift", `${shift}px`);
wrapper.style.setProperty("--embed-width", `${targetWidth}px`);
// Re-pad inner content back to the original wrapper bounds so
// toolbar buttons / first column visually align with page text.
wrapper.style.setProperty("--embed-pad", `${-shift}px`);
} }
export function BaseEmbedView({ node }: NodeViewProps) { export function BaseEmbedView({ node }: NodeViewProps) {
@@ -57,8 +54,17 @@ export function BaseEmbedView({ node }: NodeViewProps) {
const ro = new ResizeObserver(update); const ro = new ResizeObserver(update);
ro.observe(wrapper); ro.observe(wrapper);
const wider = findWiderAncestor(wrapper); // Also observe an ancestor so sidebar collapse / window changes
if (wider) ro.observe(wider); // propagate even when the wrapper itself doesn't resize.
let cur: HTMLElement | null = wrapper.parentElement;
while (cur && cur !== document.body) {
const r = cur.getBoundingClientRect();
if (r.width > wrapper.getBoundingClientRect().width + 32) {
ro.observe(cur);
break;
}
cur = cur.parentElement;
}
window.addEventListener("resize", update); window.addEventListener("resize", update);
return () => { return () => {