Skip to content

feat: sticky left columns (stickyLeftColumnNumber) - #386

Open
shahidullahkhankhattak wants to merge 1 commit into
nick-keller:masterfrom
shahidullahkhankhattak:feat/sticky-left-columns
Open

feat: sticky left columns (stickyLeftColumnNumber)#386
shahidullahkhankhattak wants to merge 1 commit into
nick-keller:masterfrom
shahidullahkhankhattak:feat/sticky-left-columns

Conversation

@shahidullahkhankhattak

@shahidullahkhankhattak shahidullahkhankhattak commented May 11, 2026

Copy link
Copy Markdown

Adds optional stickyLeftColumnNumber so the first N data columns after the gutter stay visible during horizontal scroll. Includes a small test for the sticky cell class.

Closes #326

Adds optional stickyLeftColumnNumber to pin the first N data columns
after the gutter so they stay visible during horizontal scroll.

Closes nick-keller#326

Co-authored-by: Cursor <cursoragent@cursor.com>
@netlify

netlify Bot commented May 11, 2026

Copy link
Copy Markdown

Deploy Preview for react-datasheet-grid canceled.

Name Link
🔨 Latest commit bc1c29f
🔍 Latest deploy log https://app.netlify.com/projects/react-datasheet-grid/deploys/6a01ac2e7658600008a78973

rodrigoescandon added a commit to rodrigoescandon/react-datasheet-grid that referenced this pull request Aug 5, 2026
Root cause: .dsg-row is a plain block container. .dsg-cell children are
position: absolute (removed from normal flow) except cells that need to
stick, which use position: sticky and therefore stay in normal in-flow
layout. With only the gutter column sticky (pre-nick-keller#386 baseline) there was
exactly one in-flow child per row, which happened to land at the correct
spot by luck. PR nick-keller#386 added up to stickyLeftColumnNumber more
position:sticky cells per row; being in-flow, block layout stacked them
vertically -- one row-height per extra sticky cell -- which is exactly
the observed bug (pinned header cell displaced several rows down, empty/
misaligned pinned cells in the first rows). This is the same class of
issue the pre-existing .dsg-cell-sticky-right `transform:
translateY(-100%)` hack works around for exactly 2 in-flow cells
(gutter + sticky-right); it doesn't generalize to N sticky-left columns.

Fix: keep sticky-left cells position: absolute (out of flow, like
ordinary cells) instead of position: sticky, and compute their `left` in
JS as the column virtualizer's own `col.start` (its natural, unpinned
position) plus the container's current horizontal scrollLeft, tracked in
Grid.tsx via the container's onScroll handler. This keeps them visually
pinned without touching row flow layout at all, and leaves the existing
gutter/sticky-right mechanism untouched.

Using `col.start` (rather than the `columnRights` prop) as the offset
anchor also fixes a related bug found during verification: columnRights
can be transiently undefined before react-resize-detector reports a
real container width (it deliberately skips its on-mount measurement),
which made every sticky-left cell fall back to left: 0 and collapse onto
the gutter. col.start is always populated -- it falls back to the same
100px estimate ordinary cells use -- so sticky-left cells stay aligned
with their neighbors in that window too.

- src/style.css: drop `position: sticky` from .dsg-cell-sticky-left
  (z-index kept for stacking above scrolled-under cells); add comment
  explaining why, referencing the sticky-right translateY hack.
- src/components/Grid.tsx: track scrollLeft state via the container's
  onScroll; add getStickyLeftOffset(colStart) = colStart + scrollLeft;
  wire it into both the header-row and body-row sticky-left cells.
- dist/: rebuilt (npm run build) and verified npm test passes
  (16 suites / 100 tests, including tests/stickyColumns.test.tsx).
- package.json: version -> 4.11.6-grid.2.

Verified live in the consumer app (base-de-datos-grid, packages/
payload-grid-view) against Directorio's 5391-row grid with
stickyLeftColumnNumber={2}: row 1 ('"El Chango" Cabral') and the header
render aligned; horizontal scroll keeps the gutter, open-record arrow,
and Nombre columns pinned with opaque backgrounds while other columns
slide underneath, at both small and large scroll offsets; alignment
holds during vertical scroll while horizontally scrolled; dark theme
(html[data-theme='dark'] .pgv-grid) stays legible with opaque cell
backgrounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rodrigoescandon added a commit to rodrigoescandon/react-datasheet-grid that referenced this pull request Aug 5, 2026
Root cause of the earlier JS-driven approaches (98f4543, f3168a2): any
scroll-event-driven positioning -- even one that bypasses React and writes
a CSS var straight to the DOM -- still runs on the main thread in response
to a dispatched 'scroll' event. On macOS momentum/fast-wheel scrolling the
compositor paints frames faster than the main thread can dispatch and
handle those events, so the sticky-left cells' visual position could
render a frame (or several) behind the actual scrollLeft, producing
visible drift/vanish/snap-back under fast scrolling.

Fix: make sticky-left cells (gutter + pinned data columns) real in-flow
`position: sticky` elements again, pinned by the browser's compositor with
no JS involved in the scroll path at all -- categorically immune to the
main-thread-lag failure mode, not just less prone to it.

This requires solving the original blocker from PR nick-keller#386: .dsg-row is a
plain block container, and in-flow sticky cells (unlike ordinary cells,
which are position: absolute and therefore out of flow) stack vertically
under block layout -- one row-height per sticky cell -- which is what
broke PR nick-keller#386 for N > 1 pinned columns in the first place.

Fix layout instead of abandoning sticky:

1. src/style.css: `.dsg-row { display: flex }`. Ordinary cells are
   position: absolute and therefore not flex items at all -- completely
   unaffected. The in-flow sticky cells (gutter, sticky-left, sticky-right)
   become flex items on one horizontal line instead of stacking.

2. src/components/Grid.tsx: fixed a latent DOM-order bug in the column
   virtualizer's rangeExtractor. It force-includes sticky column indices
   via repeated `result.unshift(stickyCol)` in an ascending loop, which
   builds the array in DESCENDING order (each unshift lands in front of
   the last) whenever those columns fall outside the naturally-virtualized
   range (i.e. once scrolled past them). That was harmless when sticky-left
   cells were position: absolute (DOM order didn't affect visual position,
   see f3168a2) but would render pinned columns in reverse visual order
   under flex. Now collects the missing indices and unshifts them together
   to preserate ascending order.

3. src/style.css: sticky cells get `left: col.start` (unchanged, still set
   inline per-cell in Cell.tsx) plus `flex: none` to keep their exact
   measured width regardless of available flex-line space. col.start for a
   sticky column is the cumulative width of columns 0..i-1, which -- since
   the sticky set is always the contiguous range starting at 0 -- is
   exactly the cumulative width of the *prior sticky columns*, precisely
   what `position: sticky`'s `left` needs.

4. src/style.css: removed `.dsg-cell-sticky-right`'s
   `transform: translateY(-100%)`. That hack compensated for exactly two
   in-flow children (gutter, sticky-right) stacking vertically under block
   layout; under flex, in-flow children lay out horizontally on one line,
   so the vertical stacking it compensated for no longer happens and the
   transform would now be actively wrong.

5. src/components/Grid.tsx, src/style.css: removed the --dsg-sticky-x
   custom-property/transform mechanism (and the onScroll handler that
   drove it) entirely -- no longer needed now that positioning is native
   CSS sticky.

6. src/style.css: gave `.dsg-cell-sticky-left` its own
   border-right/border-bottom (using --dsg-border-color) instead of
   relying on the shared box-shadow trick ordinary cells use to draw grid
   lines -- that trick depends on the two cells' edges staying pixel-
   aligned as the grid scrolls, which doesn't hold for a sticky cell with
   an opaque background sitting over a non-sticky neighbor. This was
   previously patched only in the consumer app's CSS
   (base-de-datos-grid/packages/payload-grid-view); folded the same fix
   into the fork so the engine is correct standalone.

Verified in the consumer app (base-de-datos-grid, Directorio, 5391 rows,
stickyLeftColumnNumber={2}): gutter + open-record arrow + Nombre stay
rock-solid pinned and opaque through rapid back-and-forth horizontal
scrolling, scrolling to the far right, and vertical scrolling while
horizontally offset (rows 51-74 checked) -- both light and dark themes.
Column resize (PR nick-keller#350) still works and correctly reflows the sticky
layout. npm test: 100/100 (one paste.test.tsx case flaked once under full-
suite timing pressure -- an act()-wrapping warning unrelated to this
change -- and passed both in isolation and on a full-suite rerun).

- src/components/Grid.tsx: rangeExtractor DOM-order fix; dropped
  handleScroll/--dsg-sticky-x wiring, onScroll passed through directly.
- src/style.css: .dsg-row display: flex; .dsg-cell-sticky-left/-right/
  -gutter position: sticky with flex: none; sticky-left border-right/
  -bottom; removed --dsg-sticky-x var and sticky-right's translateY hack.
- dist/: rebuilt (npm run build).
- package.json: version -> 4.11.6-grid.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sticky (frozen) left column needed

1 participant