diff --git a/.changeset/pinned-header-seam.md b/.changeset/pinned-header-seam.md new file mode 100644 index 00000000..aec7e0f8 --- /dev/null +++ b/.changeset/pinned-header-seam.md @@ -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. diff --git a/packages/ui/grid.css b/packages/ui/grid.css index a6146b3d..e87bbd7c 100644 --- a/packages/ui/grid.css +++ b/packages/ui/grid.css @@ -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) */ diff --git a/packages/ui/src/__tests__/css-cascade.test.ts b/packages/ui/src/__tests__/css-cascade.test.ts index b2a93019..ce39e751 100644 --- a/packages/ui/src/__tests__/css-cascade.test.ts +++ b/packages/ui/src/__tests__/css-cascade.test.ts @@ -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", () => { @@ -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.