Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/pinned-seam-continuous.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@pretable/ui": patch
"@pretable/react": patch
---

The frozen-column seam is now one continuous edge instead of a shadow that
faded out at every row boundary.

It was a `box-shadow` on each pinned CELL, and a per-cell shadow cannot tile.
The blur has to stay inside the cell — a spread any less negative bleeds above
and below it and doubles into a dark band at each boundary — so the seam faded
to nothing once per row and read as a dashed edge rather than a frozen pane's.

grid.css now draws it as one full-height gradient per plane: the sticky header
row and the scroll content, meeting exactly at the header's lower edge. A
gradient has no falloff along its own axis, so each box is uniform for its
plane's whole height. The surface publishes where each edge falls
(`--pretable-pinned-left-edge` / `--pretable-pinned-right-edge`, gated by
`data-pretable-pinned-left` / `-right`), taking the right-hand one through the
same `getPinnedRightEdge` the right-pinned cells use so the seam cannot land a
pixel off the column it marks. `--pretable-seam-color` still colours it, and a
side with nothing pinned draws nothing.
54 changes: 35 additions & 19 deletions apps/bench/tests/cascade-override.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,38 +33,54 @@ test("an unlayered consumer rule beats the layered grid default", async ({
test("a focused pinned cell keeps its seam, and draws exactly one ring", async ({
page,
}) => {
// The structural test in @pretable/ui can prove grid.css no longer DECLARES a
// The structural test in @pretable/ui can prove grid.css declares no
// box-shadow focus ring. Only a browser can prove the consequence: that the
// ring and the frozen-column seam now coexist on the same cell.
// ring and the frozen-column seam coexist while one cell holds focus.
//
// box-shadow is not additive across rules — the winning declaration replaces
// the slot outright, it does not stack. While the ring lived there too, the
// seam vanished for as long as a pinned cell held focus, which is visible in
// the house theme (pretable.css draws a real --pretable-seam-color; the two
// themes shipping when that trade was accepted both set it to transparent).
// The collision this guards used to be literal — the seam was a box-shadow
// on the CELL, box-shadow is not additive across rules, and a ring in that
// slot replaced the seam outright for as long as the cell held focus. The
// seam is now one gradient per plane (a per-cell shadow cannot tile into a
// continuous edge), so the two live on different elements and the old
// collision is impossible by construction. What is still worth proving in a
// real browser is the pair: the plane paints its seam AND the cell draws its
// ring, at the same time, from one stylesheet.
//
// The fixture mirrors what @pretable/react renders: role="gridcell" AND
// data-pretable-cell AND data-pretable-focused, all on one element. That is
// the whole reason the doubled ring was invisible in review — each rule read
// correct on its own, and only the real DOM put both on one cell.
// The fixture mirrors what @pretable/react renders: the edge published on
// the viewport, and role="gridcell" AND data-pretable-cell AND
// data-pretable-focused all on one element. That last part is the whole
// reason a doubled ring was once invisible in review — each rule read
// correct alone, and only the real DOM put both on one cell.
await page.setContent(
"<div data-pretable-scroll-viewport " +
"<div data-pretable-scroll-viewport data-pretable-pinned-left " +
'style="--pretable-seam-color: rgb(1, 2, 3); ' +
'--pretable-focus-ring: rgb(4, 5, 6)">' +
"--pretable-focus-ring: rgb(4, 5, 6); " +
'--pretable-pinned-left-edge: 40px">' +
'<div data-pretable-scroll-content style="height: 100px">' +
"<div data-pretable-row>" +
'<span data-pretable-cell data-pretable-pinned="left" ' +
'data-pretable-focused="true" role="gridcell" id="cell">x</span>' +
"</div></div>",
"</div></div></div>",
);
await page.addStyleTag({ path: GRID_CSS });

const cell = page.locator("#cell");
// The seam survives focus — it owns the box-shadow slot alone now.
await expect(cell).toHaveCSS("box-shadow", "rgb(1, 2, 3) 8px 0px 8px -8px");
// And the ring is drawn, once, as an outline. `inset` in the shadow would
// mean the second ring came back and took the seam's slot with it.
// The ring, drawn once, as an outline.
await expect(cell).toHaveCSS("outline", "rgb(4, 5, 6) solid 2px");
await expect(cell).not.toHaveCSS("box-shadow", /inset/);
// The cell's shadow slot is empty: the seam has left it, and a ring must
// never take it (box-shadow does not stack across rules — the winner
// replaces the slot, which is how the seam was lost the first time).
await expect(cell).toHaveCSS("box-shadow", "none");

// And the seam is painted, on the plane, while that cell holds focus.
const seam = await page.evaluate(() => {
const content = document.querySelector("[data-pretable-scroll-content]")!;
const s = getComputedStyle(content, "::after");
return { backgroundImage: s.backgroundImage, left: s.left, width: s.width };
});
expect(seam.backgroundImage).toContain("rgb(1, 2, 3)");
expect(seam.left).toBe("40px");
expect(seam.width).toBe("8px");
});

test("a focused cell in the REAL grid actually paints its ring", async ({
Expand Down
69 changes: 69 additions & 0 deletions packages/react/src/__tests__/right-pin-surface.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1100,3 +1100,72 @@ describe("right pin × row grouping", () => {
expect(colCount(container)).toBe("4");
});
});

describe("the frozen edges the seam is drawn from", () => {
// grid.css draws the seam ONCE per plane rather than per cell — a per-cell
// box-shadow cannot tile into a continuous edge — so the surface has to
// publish where each edge falls. jsdom proves only what is emitted; that the
// gradient lands on the boundary is a browser assertion.
const viewport = (container: HTMLElement) =>
container.querySelector<HTMLElement>("[data-pretable-scroll-viewport]")!;

it("publishes both edges, in the same viewport-x the cells are pinned at", () => {
const { container } = renderSurface();
const el = viewport(container);

expect(el).toHaveAttribute("data-pretable-pinned-left");
expect(el).toHaveAttribute("data-pretable-pinned-right");
// Left: the pinned run's own width. Right: taken through the same
// getPinnedRightEdge the cells use, so the seam cannot land a pixel off
// the column it marks.
expect(el.style.getPropertyValue("--pretable-pinned-left-edge")).toBe(
`${LEFT_WIDTH}px`,
);
expect(el.style.getPropertyValue("--pretable-pinned-right-edge")).toBe(
`${VIEWPORT_WIDTH - RIGHT_PREV_WIDTH - RIGHT_LAST_WIDTH}px`,
);
// The right-pinned CELLS agree with the edge the seam is given: the
// leading one starts exactly there.
expect(bodyCell(container, "status")!.style.left).toBe(
`${VIEWPORT_WIDTH - RIGHT_PREV_WIDTH - RIGHT_LAST_WIDTH}px`,
);
});

it("publishes nothing for a side with nothing pinned", () => {
// Without this the stylesheet would resolve an unset edge to zero and draw
// a seam down the grid's own left border, marking a boundary that is not
// there.
const { container } = render(
<PretableSurface
ariaLabel="unpinned-grid"
columns={[
{ id: "first", header: "First", widthPx: LEFT_WIDTH },
{ id: "b", header: "B", widthPx: 100 },
]}
getRowId={(row: PinRow) => row.id}
overscan={0}
rows={rows}
viewportHeight={200}
/>,
);
const el = viewport(container);

expect(el).not.toHaveAttribute("data-pretable-pinned-left");
expect(el).not.toHaveAttribute("data-pretable-pinned-right");
expect(el.style.getPropertyValue("--pretable-pinned-left-edge")).toBe("");
expect(el.style.getPropertyValue("--pretable-pinned-right-edge")).toBe("");
});

it("withholds the right edge until the scrollport has been measured", () => {
// The right edge is viewportWidth-relative, exactly like the cells' own
// sticky inset: before the first measure those cells are not pinned at
// all, and a seam drawn at a guessed edge would sit in open space.
clientWidth = 0;
const { container } = renderSurface();
const el = viewport(container);

expect(el).toHaveAttribute("data-pretable-pinned-left");
expect(el).not.toHaveAttribute("data-pretable-pinned-right");
expect(el.style.getPropertyValue("--pretable-pinned-right-edge")).toBe("");
});
});
22 changes: 22 additions & 0 deletions packages/react/src/pretable-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import {
getScrollContentStyle,
getToolPanelGridAreaStyle,
getToolPanelLayoutStyle,
getSeamStyle,
getViewportStyle,
} from "./styles";
import {
Expand Down Expand Up @@ -6324,9 +6325,30 @@ export function PretableSurface<
setViewportWidth(el.clientWidth);
}
}}
// The frozen edges, for the seam grid.css draws once per plane. Both
// planes (header row and scroll content) are descendants, so the two
// custom properties are published here and inherit; the attributes are
// what the stylesheet keys on, so a side with nothing pinned draws
// nothing rather than a seam at x=0. AFTER the consumer's
// `viewportStyle` on purpose: these are the surface's own layout math,
// not skin, and a stray consumer style must not move a frozen edge off
// the column it marks.
data-pretable-pinned-left={
renderSnapshot.pinnedLeftWidth > 0 ? "" : undefined
}
data-pretable-pinned-right={
renderSnapshot.pinnedRightWidth > 0 && viewportWidth > 0
? ""
: undefined
}
style={{
...getViewportStyle(scrollViewportHeight),
...viewportStyle,
...getSeamStyle(
renderSnapshot.pinnedLeftWidth,
renderSnapshot.pinnedRightWidth,
viewportWidth,
),
}}
>
<div
Expand Down
45 changes: 45 additions & 0 deletions packages/react/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,51 @@ export function getViewportStyle(height: number): CSSProperties {
};
}

/**
* Where each frozen edge falls, published to CSS so the seam can be drawn ONCE.
*
* The seam used to be a `box-shadow` on every pinned CELL, and a per-cell
* shadow cannot tile into a continuous edge: a negative spread keeps the blur
* from bleeding past each cell (and so fades to nothing at every row
* boundary), while a spread large enough to reach the cell's edges makes
* neighbouring shadows overlap and paint a dark band there instead. Either way
* the frozen edge visibly starts and stops once per row. grid.css now draws it
* as one full-height gradient per plane — the header row and the scroll
* content — and these two properties are the only thing it cannot work out for
* itself.
*
* Both are viewport-x, matching how the cells themselves are pinned: the
* right-hand group is stuck by `left` too (see {@link getPinnedRightCellStyle}),
* so its edge comes from {@link getPinnedRightEdge} rather than a `right`
* inset — the same call the cells make, so the seam cannot land a pixel off the
* column it belongs to. An unmeasured viewport (`viewportWidth` 0, before the
* first measure) leaves the right edge unset exactly as it leaves those cells
* unpinned.
*
* A side with nothing pinned publishes nothing: grid.css keys the seam on the
* `data-pretable-pinned-left`/`-right` attributes, so an absent property can
* never resolve to a seam drawn at x=0.
*/
export function getSeamStyle(
pinnedLeftWidth: number,
pinnedRightWidth: number,
viewportWidth: number,
): CSSProperties {
const rightEdge =
pinnedRightWidth > 0
? getPinnedRightEdge(viewportWidth, pinnedRightWidth)
: undefined;

return {
...(pinnedLeftWidth > 0
? { "--pretable-pinned-left-edge": `${pinnedLeftWidth}px` }
: {}),
...(rightEdge !== undefined
? { "--pretable-pinned-right-edge": `${rightEdge}px` }
: {}),
} as CSSProperties;
}

/**
* The horizontal row the surface renders when the tool panel is enabled:
* `[vertical grid stack][pane?][rail]`. Stretch (the default cross-axis
Expand Down
Loading