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
18 changes: 18 additions & 0 deletions .changeset/pinned-header-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@pretable/ui": patch
---

The frozen-column seam now runs through the header row instead of starting
below it.

`--pretable-seam-color` was drawn by `[data-pretable-cell][data-pretable-pinned]`
only. The pinned HEADER cells took the opaque `--pretable-bg-header` fill and
nothing else, so the shadow that marks the frozen edge stopped dead at the
header — a header-tall gap at the top of a boundary that is meant to run the
height of the grid, visible on any grid with a pinned column and a horizontal
scroll. The header's pinned rule is now split per side, each carrying the
mirrored offset its body counterpart has.

The guard that should have caught this named `[data-pretable-cell]` alone; it
now covers the header rules too, on both sides, and checks that the seam did
not cost the opaque fill.
21 changes: 16 additions & 5 deletions packages/ui/grid.css
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,23 @@
the whole strip, but each header cell on top of it is a transparent box,
so an unpinned header's label reads straight through a pinned one once it
scrolls underneath. Pinned header cells need their own opaque fill, the
same one their body counterparts get below. */
:where(
[data-pretable-header-cell][data-pretable-pinned="left"],
[data-pretable-header-cell][data-pretable-pinned="right"]
) {
same one their body counterparts get below.
They need the SEAM as well, and for a while they had only the fill: the
frozen edge is one boundary running the height of the grid, and the body
rule below draws it for the body rows only, so it stopped dead at the
header and left a header-tall gap in the middle of the seam. Split in two
rather than one rule for both sides because the offset has to mirror —
the same reason --pretable-seam-color holds a colour and not a shadow.
Only the OUTERMOST pinned cell's seam is ever seen: pinned siblings share
z-index 1, so each one paints over the shadow the cell before it cast,
exactly as the body's do. */
:where([data-pretable-header-cell][data-pretable-pinned="left"]) {
background: var(--pretable-bg-header);
box-shadow: 8px 0 8px -8px var(--pretable-seam-color);
}
:where([data-pretable-header-cell][data-pretable-pinned="right"]) {
background: var(--pretable-bg-header);
box-shadow: -8px 0 8px -8px var(--pretable-seam-color);
}

/* Multi-sort priority badge (rendered only when 2+ columns are sorted) */
Expand Down
37 changes: 33 additions & 4 deletions packages/ui/src/__tests__/css-cascade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,17 @@ describe("grid.css cascade contract", () => {
const css = fs.readFileSync(GRID_CSS, "utf8");
// The header row's own background sits BEHIND its cells; a transparent
// pinned header cell lets a scrolled-under header's label read through it.
const rule = css.match(
/:where\(\s*\[data-pretable-header-cell\]\[data-pretable-pinned="left"\],\s*\[data-pretable-header-cell\]\[data-pretable-pinned="right"\]\s*\)\s*\{[^}]*\}/,
);
expect(rule?.[0]).toMatch(/background:\s*var\(--pretable-bg-header\)/);
// One rule per side, because each also carries its own mirrored seam (see
// the pinned-seam test) — a shared rule cannot hold two offsets.
for (const side of ["left", "right"]) {
const rule = css.match(
new RegExp(
`:where\\(\\[data-pretable-header-cell\\]\\[data-pretable-pinned="${side}"\\]\\)\\s*\\{[^}]*\\}`,
),
);
expect(rule?.[0], `no ${side}-pinned header rule`).toBeDefined();
expect(rule?.[0]).toMatch(/background:\s*var\(--pretable-bg-header\)/);
}
});

test("pinned body cells and group rows have their own surface tokens", () => {
Expand Down Expand Up @@ -97,6 +104,28 @@ describe("grid.css cascade contract", () => {
/box-shadow:\s*-8px 0 8px -8px var\(--pretable-seam-color\)/,
);

// The HEADER's pinned cells draw the same seam with the same offsets. The
// frozen edge is one boundary running the height of the grid; a rule that
// reaches the body rows only leaves a header-tall gap in the middle of it,
// which is what shipped while this guard named the body cell alone.
const headerLeft = css.match(
/:where\(\[data-pretable-header-cell\]\[data-pretable-pinned="left"\]\)\s*\{([\s\S]*?)\}/,
)?.[1];
const headerRight = css.match(
/:where\(\[data-pretable-header-cell\]\[data-pretable-pinned="right"\]\)\s*\{([\s\S]*?)\}/,
)?.[1];
expect(headerLeft, "no left-pinned HEADER rule").toBeDefined();
expect(headerRight, "no right-pinned HEADER rule").toBeDefined();
expect(headerLeft).toMatch(
/box-shadow:\s*8px 0 8px -8px var\(--pretable-seam-color\)/,
);
expect(headerRight).toMatch(
/box-shadow:\s*-8px 0 8px -8px var\(--pretable-seam-color\)/,
);
// Same opaque fill as before — the seam must not have cost it.
expect(headerLeft).toMatch(/background:\s*var\(--pretable-bg-header\)/);
expect(headerRight).toMatch(/background:\s*var\(--pretable-bg-header\)/);

// And a frozen column must not punch a notch through a group band: the
// pinned rules follow the group-row rule at equal specificity, so the
// restoring rule has to come after BOTH of them.
Expand Down