Skip to content

feat: pinned-column + sticky-header shadows, scrollProps wrapper, generalized borders#28

Merged
gfazioli merged 34 commits into
masterfrom
feat/sticky-column-shadow
May 6, 2026
Merged

feat: pinned-column + sticky-header shadows, scrollProps wrapper, generalized borders#28
gfazioli merged 34 commits into
masterfrom
feat/sticky-column-shadow

Conversation

@gfazioli
Copy link
Copy Markdown
Owner

@gfazioli gfazioli commented May 4, 2026

Closes #27.

Originally scoped to pinned-column visual feedback, this PR has grown into a broader pass on the table's outer wrapper, sticky behaviors, and the Styles API surface. All changes are additive — no breaking changes — so this targets a minor (4.1.0) release.

Highlights

Pinned columns

  • column.sticky extended from boolean to boolean | 'left' | 'right'; true keeps meaning 'left' (full backward compat).
  • Subtle gradient shadow on the inner edge of the last sticky-left and first sticky-right column, fading in/out smoothly as the table scrolls horizontally. Driven by useStickyShadow via requestAnimationFrame-throttled scroll events — no React re-renders during scroll.
  • Customization: --lvt-shadow-color (now actually wired up — was documented but unused before) and --lvt-shadow-width.

Sticky header shadow

  • Matching drop shadow under <thead> while it is stuck at stickyHeaderOffset, fading back out when sticky releases. Same UX pattern as the column shadow.
  • New internal useStickyHeaderShadow hook drives --lvt-header-shadow-opacity; full shadow shape/color is customizable via classNames.header / styles.header.

Outer wrapper border + scroll

  • New scrollProps?: { minWidth?, maxHeight? } renders a non-scrolling outer wrapper with native horizontal/vertical scroll inside, so withTableBorder and borderRadius stay fixed at the edges during scroll — the recommended setup with sticky columns.
  • New borderRadius?: MantineRadius (default 'sm') and new borderWidth?: number | string props on the wrapper.
  • Border is now drawn on the .root wrapper (not on Mantine's <table>) so it's stable regardless of scroll. Internal viewport uses clip-path to clip the rounded corners — overflow: hidden was avoided because it would create a scroll containing block and break position: sticky on the thead.

Documentation & Styles API

  • Reorganized the docs TOC: scroll-related sections (Sticky Header, Pinned Columns, Horizontal Scrolling, Scroll Area) are now contiguous; Keyboard Navigation is a sub-section of Row Selection.
  • Audited ListViewTable.styles-api.ts against the implementation — added missing modifiers (data-with-table-border, data-with-scroll, data-dragging/data-resizing on root; data-focused on row/headerCell), updated selector descriptions, corrected the default --lvt-shadow-width (8px), removed stale entries.
  • README updated to mention the new shadows and border props.
  • Configurator demo now exposes borderRadius and borderWidth controls.

Test plan

  • Pinned columns: gradient shadow appears/fades on horizontal scroll, both light and dark
  • Sticky header: drop shadow appears while stuck, fades when scrolling past the table
  • borderRadius + borderWidth (0–8) render without artifacts on all 4 corners
  • scrollProps keeps the border fixed during horizontal scroll
  • Table.ScrollContainer still works (legacy path)
  • Sticky columns + scrollProps + sticky header together render correctly
  • yarn test (47/47)
  • yarn build

🤖 Generated with Claude Code

gfazioli added 2 commits May 4, 2026 16:19
Closes #27.

API:
- Extend `ListViewTableColumn['sticky']` from `boolean` to
  `boolean | 'left' | 'right'`. `true` is preserved as an alias for
  `'left'` (full backward compatibility).
- New `useStickyShadow` hook (exported alongside the other column hooks)
  watches horizontal scroll on the closest scrollable ancestor and writes
  `--lvt-shadow-left-opacity` / `--lvt-shadow-right-opacity` on the
  table via `requestAnimationFrame`-throttled scroll events. A
  `ResizeObserver` covers the layout-after-mount cases (Mantine
  `ScrollArea` viewport, font reflows, column resizing).

Rendering:
- New `stickyColumnShadow` Styles API selector — a real `<span>`
  rendered inside the *last* sticky-left and *first* sticky-right header
  and body cells. Its opacity is driven by the CSS variables above; no
  React re-renders during scroll. Customize via `--lvt-shadow-color`
  (default rgba(0,0,0,0.15) light / rgba(0,0,0,0.4) dark) and
  `--lvt-shadow-width` (default 4px).
- Sticky cells switch to `overflow: visible` so the shadow can render
  outside the cell border. Ellipsis rules move to an inner wrapper for
  sticky cells; non-sticky cells keep the existing structure.
- New `data-sticky-side` (`"left"` / `"right"`) and
  `data-sticky-shadow` modifiers on header and body cells.

Scroll-ancestor detection:
- Match any ancestor with `overflow-x !== 'visible'`. Mantine
  `ScrollArea`'s viewport uses `overflow-x: hidden` while still
  driving `scrollLeft` programmatically, so an `auto`/`scroll`-only
  match would fall back to `window` and miss the real scroll target.

Tests: 47/47 passing — new suites for the hook (initial mount + disabled
short-circuit) and the multi-side pinning (data-sticky-side, shadow
attribution, sticky=true backward-compat).
- New `docs/demos/ListViewTable.demo.pinnedColumns.tsx` showing
  `sticky: 'left'` and `sticky: 'right'` together inside a
  `Table.ScrollContainer` so the gradient shadow is visible during
  horizontal scroll.
- New "Pinned Columns" section in `docs.mdx` between "Sticky Header"
  and "Row Selection" with code example and a note about the
  CSS variables for theming the shadow.
- Update `ListViewTable.styles-api.ts` with:
  - the new `stickyColumnShadow` selector
  - the four new CSS variables (`--lvt-shadow-color`,
    `--lvt-shadow-width`, `--lvt-shadow-left-opacity`,
    `--lvt-shadow-right-opacity`)
  - new modifiers for `data-sticky-side` (cell + headerCell) and
    `data-side` (stickyColumnShadow)
Copilot AI review requested due to automatic review settings May 4, 2026 14:20
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR enhances ListViewTable column pinning by adding support for right-side sticky columns (sticky: 'right') and introducing a scroll-driven gradient shadow cue on the inner edge of pinned columns, with documentation, demos, and tests to validate the behavior.

Changes:

  • Extended column.sticky to support boolean | 'left' | 'right' (with true preserved as 'left').
  • Added useStickyShadow hook to update shadow opacity CSS variables based on horizontal scroll (rAF-throttled).
  • Rendered a new stickyColumnShadow Styles API slot and updated docs/demos/styles-api accordingly.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
package/src/types.ts Extends sticky typing and registers new Styles API selector + CSS variables.
package/src/ListViewTable.tsx Implements left/right sticky handling, shadow span rendering, and wires useStickyShadow.
package/src/ListViewTable.test.tsx Adds coverage for sticky side attributes and shadow span rendering rules.
package/src/ListViewTable.module.css Adds .stickyColumnShadow styling and new shadow CSS variables/defaults.
package/src/index.ts Re-exports useStickyShadow and its options type from the package entry.
package/src/hooks/use-sticky-shadow.ts New hook to compute and write --lvt-shadow-*-opacity on scroll/resize/RO updates.
package/src/hooks/use-sticky-shadow.test.tsx Adds basic tests for enabled/disabled behavior and initial variable writes.
package/src/hooks/index.ts Exports the new hook from the hooks barrel.
docs/styles-api/ListViewTable.styles-api.ts Documents new selector and shadow-related CSS variables/modifiers.
docs/docs.mdx Adds “Pinned Columns” docs section describing left/right pinning and shadow theming.
docs/demos/ListViewTable.demo.pinnedColumns.tsx Adds a demo showcasing both-side pinning and horizontal scroll.
docs/demos/index.ts Registers the new pinned columns demo export.

Comment thread package/src/ListViewTable.tsx
Comment thread package/src/ListViewTable.tsx
Comment thread package/src/ListViewTable.tsx
Comment thread package/src/ListViewTable.tsx
Comment thread package/src/hooks/use-sticky-shadow.ts Outdated
Comment thread package/src/ListViewTable.module.css Outdated
Comment thread package/src/ListViewTable.tsx Outdated
gfazioli added 14 commits May 4, 2026 16:27
Mirror mantine-datatable's sticky-column rendering: pinned columns now
have a fully opaque background (`var(--mantine-color-body)` light /
`var(--mantine-color-dark-7)` dark) so the columns underneath are
visually occluded as the table scrolls horizontally. The only edge cue
remains the gradient shadow span introduced by this PR.

The previous implementation used a `::before` pseudo-element with
`backdrop-filter: blur(var(--list-view-sticky-blur, 3px))`, which made
the underlying content visible-but-blurred — distracting and a regression
versus mantine-datatable's reference behavior.

Removed:
- The `::before` pseudo-element on `.stickyColumn` and
  `.stickyHeaderColumn` (with all backdrop-filter rules)
- `--list-view-sticky-blur` from `ListViewTableCssVariables`,
  the varsResolver, and the Styles API docs

The variable was unused outside the removed blur rule, so the cleanup
does not break any other styling path.
…xtension

Two issues addressed:

1. Right-pinned cells were also receiving the legacy `left: 0` from the
   `.stickyColumn` / `.stickyHeaderColumn` class while the renderer set
   `right: 0` inline. With both `left: 0` and `right: 0` simultaneously
   the browser stretched the cell unpredictably and the underlying scroll
   content briefly leaked at the edges. The `left: 0` is removed from
   the class — `position: sticky`, `left`, and `right` are now set
   inline on every sticky cell, with `left: 'auto'` / `right: 'auto'`
   set explicitly on the *opposite* side to neutralize any stale class
   rule.

2. The gradient shadow span fades to transparent on its outer edge, so
   without something opaque underneath the shadow region the user could
   see fragments of the underlying scroll content drifting through the
   shadow as columns went past. Adding a sharp `box-shadow` of the
   cell's own background color (`--mantine-color-body`) on the inner
   edge of the last sticky-left and first sticky-right cells extends the
   opaque background by `--lvt-shadow-width` outside the cell border.
   The gradient now fades into solid cell color rather than into
   bleeding-through scroll content — same visual result as
   mantine-datatable.
…ension

The earlier `box-shadow` extension fix tried to push opaque cell color
4px outside the cell border, but the offset shadow visually overlapped
the table's outer borders during scroll, and the header sticky cells
ended up looking translucent because their background no longer matched
the surrounding row.

This commit replaces the workaround:

- Sticky cells declare `--lvt-sticky-bg` (= `var(--mantine-color-body)`,
  with a dark-scheme override). The class uses `background: var(--lvt-sticky-bg)`
  so the cell paints an explicit, opaque color — no `inherit` (which
  bleeds through because `<tr>` has no background of its own) and no
  cross-cutting box-shadow.
- The shadow gradient now ends at `var(--lvt-sticky-bg)` instead of
  `transparent`. Both stops are fully opaque, so the gradient transitions
  into solid cell color and the underlying scroll content cannot peek
  through the shadow strip while it scrolls past.
- The earlier box-shadow rules on `[data-sticky-shadow]` cells are
  removed entirely — the gradient itself is now self-occluding.
…tatable

Following user feedback that the previous `<span>`-based shadow caused
header transparency, vanishing outer borders, and content peek-through
during scroll, the implementation is rewritten to match
`mantine-datatable` exactly:

- Drop the dedicated `stickyColumnShadow` Styles API selector (and the
  associated `<span>` element rendered inside the cell). The shadow is
  now an `::after` pseudo-element keyed off the existing
  `data-sticky-shadow` attribute on the sticky cell itself.
- Use a stacked two-stop linear gradient at low alpha
  (`rgba(0,0,0,0.05)` light / `rgba(0,0,0,0.25)` dark, mirroring
  mantine-datatable's `--mantine-datatable-shadow-background-*` values).
  The lower alpha keeps the shadow visible without bleeding the
  underlying content through.
- Sticky cells declare `overflow: visible` directly in the class so the
  pseudo-element is never clipped, and keep an opaque background via
  `--lvt-sticky-bg` (theme-aware: `--mantine-color-body` light,
  `--mantine-color-dark-7` dark) — `<tr>` has no background so any
  `inherit`-based fallback would let scroll content through.
- Width default bumped from 4px to 8px to match mantine-datatable's
  visual weight.
- Updated the Jest test to assert `data-sticky-shadow` on the cell
  instead of the removed `<span>` (47/47 still passing).
- Updated the Styles API docs accordingly: removed
  `stickyColumnShadow` selector, added `data-sticky-shadow` modifier
  for both `cell` and `headerCell`.
Root cause of the user-visible 'header transparent + scroll content
peek-through' was a CSS specificity battle:

- Existing rule: `[data-mantine-color-scheme='dark'] .headerCell {
  background: transparent }` — specificity (0, 1, 1).
- My sticky rule:  `.stickyHeaderColumn { background: var(--lvt-sticky-bg) }`
  — specificity (0, 1, 0).

The .headerCell rule won the cascade in dark mode and reset the sticky
header cell's background to `transparent`. With a transparent
background the underlying scroll content was visible through the cell —
exactly the artifact the user reported (column titles overlapping like
"NSize", header looking transparent, etc.).

Fix: add an explicit `background: var(--lvt-sticky-bg)` to the
existing dark-mode override (which already targets the sticky classes).
The override now matches the .headerCell rule's specificity (0, 1, 1)
AND comes later in the stylesheet, so the cascade picks the sticky
rule. Verified live: the sticky header cell's computed
`backgroundColor` is now `rgb(36, 36, 36)` (was `rgba(0, 0, 0, 0)`)
and the visual artifacts are gone.
Adds an opt-in built-in scroll wrapper that solves the long-standing
"outer borders disappear during horizontal scroll" issue when using
sticky columns + `withTableBorder`. Inspired by user feedback after
trying multiple workarounds at the cell level.

API:
- `scrollProps?: { minWidth?, maxHeight? }` — when set, ListViewTable
  renders its own native-scroll viewport around the `<Table>` instead
  of relying on `Mantine.Table.ScrollContainer`. The OUTER `<Box>`
  becomes non-scrolling and carries the border + border-radius, so they
  stay fixed regardless of scroll position.
- `borderRadius?: MantineRadius` — radius of the outer container when
  `scrollProps` is set (default `'sm'`).
- New `scrollViewport` Styles API selector for the inner viewport div.

Implementation notes:
- The scroll wrapper is rendered as a real `<div>` always, but uses
  `display: contents` when `scrollProps` is not set so it disappears
  from the layout entirely — preserves the previous (no-internal-scroll)
  behavior with zero side effects for consumers who keep using
  `Table.ScrollContainer`.
- When `scrollProps` is set the renderer forces `withTableBorder=false`
  on Mantine's `<Table>` so the only border is the one we draw on the
  outer `<Box>` (no double border).
- CSS uses `var(--mantine-color-default-border)` (light) and
  `var(--mantine-color-dark-4)` (dark) for the border color so it
  matches Mantine's table border styling automatically.
- The `useStickyShadow` hook auto-detects the closest scrollable
  ancestor, so it picks up the new internal viewport without any code
  change.

Demo + docs: the Pinned Columns demo now uses
`scrollProps={{ minWidth: 1300 }}` + `borderRadius="md"` instead of
wrapping in `Table.ScrollContainer`. The docs section gains a
"Recommended setup with scrollProps" subsection explaining when to
use which wrapper. Visual verification (Chrome): all four outer
borders stay continuous during horizontal scroll, rounded corners
preserved, no content peek-through inside sticky cells.
Replace `Table.ScrollContainer` wrappers with the built-in
`scrollProps={{ minWidth }}` + `borderRadius` API in both demos so the
outer border and rounded corners stay fixed during horizontal scroll.

Update the Horizontal Scrolling section in docs.mdx to recommend
`scrollProps` while noting that `Table.ScrollContainer` is still
supported (with the trade-off that the border moves with the scroll
content).
Move the table border + border-radius onto the outer `<Box>` wrapper
unconditionally, instead of only when `scrollProps` is set. Mantine's
native `withTableBorder` on the inner `<table>` is now always disabled
so the wrapper owns the border in both scroll and non-scroll modes —
keeping the visual identical, but making `borderRadius` effective on
every configuration with `withTableBorder=true`.

Add a `borderRadius` size control to the Usage configurator so the new
prop is discoverable from the first demo without requiring the user to
also enable `scrollProps`.
overflow: hidden on the wrapper made it a sticky containing block, which
trapped the thead inside .root and prevented sticky positioning relative
to the page viewport. Use clip-path: inset(0 round X) to clip the rounded
corners visually without establishing a scroll containing block.
Previously clip-path used the default border-box reference, which clipped
slightly into the top-left/top-right corners of the wrapper border due to
antialiasing at the rounded edge. Switching to padding-box keeps the clip
strictly inside the border, so the corner border line stays intact.
box-shadow:inset (previous attempt) was painted underneath the cell
backgrounds and ended up invisible. Restoring the real CSS border on
.root and moving the rounded-corner clip-path onto the inner viewport
keeps the border line untouched while still clipping the table content
to the rounded shape. Sticky header and horizontal-scroll-with-sticky-
columns both continue to work.
- New `borderWidth` prop lets consumers control the outer wrapper
  border thickness when `withTableBorder` is set; exposed in the Usage
  configurator alongside the existing `borderRadius` control.
- New `useStickyHeaderShadow` hook drives `--lvt-header-shadow-opacity`
  on the table, fading in a soft drop shadow under the thead while it
  is stuck at `stickyHeaderOffset` — same UX pattern as the existing
  sticky-column gradient shadow, and zero React re-renders during
  scroll.
- Wire up `--lvt-shadow-color` (already documented but not actually
  read) into the sticky-column gradient: consumers can now override the
  pinned-column shadow tint via `vars`.
- Document the existing `--lvt-header-shadow-opacity` driver in the
  Styles API and point users to `classNames.header` / `styles.header`
  for full shadow customization (color/blur/spread).
- Audit ListViewTable.styles-api.ts to match the implementation:
  add missing modifiers (`data-with-table-border`,
  `data-with-scroll`, `data-dragging` / `data-resizing` on root;
  `data-focused` on row and headerCell; `data-sticky-shadow` on
  headerCell), drop the stale `data-sticky` aliases, refresh selector
  descriptions, and correct the default `--lvt-shadow-width` (8px).
- README: mention the new pinned-column / sticky-header shadows and the
  outer `borderWidth` prop.
- Move `Horizontal Scrolling` and `Scroll Area Integration` next to
  `Pinned Columns` so all scroll-related material is contiguous; the
  Pinned Columns section no longer has its own `Recommended setup`
  sub-block (the same guidance now lives in Horizontal Scrolling).
- Demote `Keyboard Navigation` from H2 to H3 under `Row Selection` —
  per the existing copy, it is automatically enabled by
  `selectionMode` and belongs with the selection material.
- Mention the new sticky-header drop shadow in the Sticky Header
  section and the new `borderWidth` prop / shadow customization
  variables in the Pinned Columns section.
@gfazioli gfazioli changed the title feat: shadow effect on pinned columns + right-side pinning feat: pinned-column + sticky-header shadows, scrollProps wrapper, generalized borders May 5, 2026
- ListViewTable.tsx: replace stale 'shadow span' wording with '::after
  pseudo' (the shadow rendering was rewritten back to a pseudo-element
  in 0b06401 but the inline comment in renderHeaderCell still referred
  to the old span-based approach).
- use-sticky-shadow.ts: rewrite the JSDoc RTL paragraph — RTL styling is
  not actually shipped, so don't imply [dir='rtl'] CSS rules exist.
  Replaced with a note that the hook reports the magnitude only and
  RTL consumers should override the shadow CSS themselves.

No behavior change.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 7 comments.

Comment thread package/src/types.ts Outdated
Comment thread package/src/ListViewTable.tsx Outdated
Comment thread package/src/ListViewTable.tsx
Comment thread package/src/ListViewTable.module.css Outdated
Comment thread package/src/ListViewTable.tsx
Comment thread package/src/ListViewTable.tsx
Comment thread package/src/hooks/use-sticky-header-shadow.ts Outdated
gfazioli added 6 commits May 5, 2026 10:58
…iners

Detection used to compare `thead.top` (in viewport coords) directly to
`stickyHeaderOffset`, which only matches when the table is sticky to
the page itself. Inside Mantine's `ScrollArea` (or any container with
`scrollProps.maxHeight`), the thead is sticky relative to the inner
viewport, so the threshold becomes `scrollerTop + offset`. Add the
scroller's top to the comparison so the shadow fades in correctly in
both modes.
…ehind it

Inside a fixed-height container (Mantine `ScrollArea`,
`scrollProps.maxHeight`, etc.) the thead's natural position already
coincides with its sticky position — so the previous position-based
detection treated the header as 'stuck' from the start, painting the
shadow even when no content was scrolled past.

Switch to a per-mode signal:
- Page-scrolled: keep the position check (thead.top vs offset).
- Container-scrolled: use `scroller.scrollTop > 0` so the shadow only
  appears once the user has scrolled content underneath the thead.
The .root:focus-visible outline kept the entire table highlighted even
after the user moved focus into a specific row, which produced two
overlapping outlines (table + row). Limit the wrapper outline to the
case where no row has keyboard focus yet — i.e., right after tabbing
in — so as soon as arrow keys engage the row outline takes over.
The demo previously rendered a 3-column table with no interactive
features, so half of the Styles API selectors (`resizeHandle`,
`dragHandle`, `stickyColumn` / `stickyHeaderColumn`,
`scrollViewport`, `selectedRow`, `focusedRow`) were never present in
the DOM and consumers couldn't actually try styling them.

Switch the demo to a richer setup that exercises the full surface:
column reordering + resizing, multi-row selection (so keyboard nav
shows `focusedRow`), a sticky-left column with the header drop
shadow on `stickyHeader`, and `scrollProps` so the inner
`scrollViewport` is in the tree.
Four 220px-wide columns made the inner viewport tight and visually
crowded. Two columns (sticky-left Name + Modified) are enough to render
every selector listed in the panel — sticky column, drag handle, resize
handle, scroll viewport, header shadow, selected/focused row — without
horizontal cramping. Also drop the horizontal `scrollProps.minWidth`
since the table now fits, and keep `maxHeight: 280` so the vertical
`scrollViewport` and the sticky-header drop shadow still engage.
Mantine's sticky thead resolves to z-index 3 (or 4 with
`withTableBorder`), but our sticky-left/right body cells use z-index
10 — so when the user scrolled inside a fixed-height container, the
sticky-left first body cell would peek above the pinned thead and
'duplicate' the first row next to the column. Match Mantine's
selector specificity (`.header[data-sticky]`) and bump the thead to
z-index 12 so the header always wins on vertical scroll.

The rule only applies when the thead is in sticky mode, so it has no
effect on non-sticky tables.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.

Comment thread package/src/types.ts Outdated
Comment thread package/src/ListViewTable.tsx Outdated
Comment thread package/src/ListViewTable.module.css Outdated
Comment thread package/src/hooks/use-sticky-shadow.ts
Comment thread package/src/hooks/use-sticky-header-shadow.ts Outdated
Comment thread package/src/hooks/use-sticky-header-shadow.ts
gfazioli added 11 commits May 5, 2026 16:15
- borderRadius: route through Mantine's `getRadius()` so numeric and
  custom values (e.g. `12`, `'1rem'`) resolve to valid CSS instead of
  generating an undefined `var(--mantine-radius-${X})`. Same fix
  applied to the inner `clip-path`.
- borderColor: regression — Mantine's native `<table>` border is
  always disabled now, so the public `borderColor` prop no longer
  reached any visible border. Resolve it through Mantine's
  `getThemeColor()` in the `varsResolver` and expose
  `--lvt-border-color`; the wrapper border now reads that var with a
  fallback to `--mantine-color-default-border` (light) or
  `--mantine-color-dark-4` (dark).
- stickyHeaderOffset: the page-scrolled detection used to call
  `parseFloat(offset)`, which dropped units like `'2rem'`. Read
  `getComputedStyle(thead).top` instead — that value is always in
  pixels regardless of how the user wrote the offset.
- JSDoc: `column.sticky` referenced a non-existent
  `stickyColumnShadow` selector → point users to the actual
  `stickyColumn` / `stickyHeaderColumn` pseudo-element approach.
- JSDoc: `withTableBorder` no longer 'falls back to Mantine's native
  `<table>` border' — refresh the description to say the wrapper
  always owns the border.
- CSS comment: `.scrollViewport` no longer uses `display: contents`;
  rewrite the surrounding comment to describe the actual conditional
  inline styles applied by the renderer.
- Tests: add `use-sticky-header-shadow.test.tsx` covering the
  enabled/disabled and container-scrolled (`scrollTop > 0`) branches.
When a sticky column was active alongside `enableColumnResizing`, the
resize handle visually responded but the columns snapped back to even
distribution. Chromium ignores explicit `width` on table cells with
`position: sticky` once `table-layout: fixed` kicks in — the
auto-distributed track width wins, so the inline `style.width` set by
`useColumnResize` had no effect.

Pin both `min-width` and `max-width` to the resize width while resize
is active. The hook already clamps drag deltas against the column's
`minWidth` / `maxWidth` constraints before writing
`columnWidths[key]`, so locking min/max to the same value is safe and
forces the layout to honor the resized track regardless of cell
positioning.
The previous `isResizeActive ? 'fixed' : ...` heuristic ran into a
Chromium quirk: under `table-layout: fixed` the renderer
re-distributes track widths evenly when any cell is `position:
sticky`, ignoring the inline `width` (and even `min-width` /
`max-width`) we set from `useColumnResize`. With a sticky-left
column + `enableColumnResizing` the drag handle visually responded
but the columns snapped right back to the even split.

`auto` layout honors the inline cell widths; the existing
`getTableStyle` already locks the table's outer width to a fixed
pixel value, so we don't lose the 'no surprise reflow' guarantee
`fixed` was meant to provide. Also drop the speculative
`min-width`/`max-width` pin from the previous attempt — it was
ineffective (Chromium ignores those on sticky cells in fixed mode
too) and is no longer needed under `auto`.
The previous attempt switched table-layout from `fixed` to `auto`
during resize when sticky columns were present. That worked for the
narrow case but introduced two regressions:
- Auto layout proportionally redistributes when the sum of inline
  cell widths is less than the table width, so explicit widths set
  by the resize hook didn't always take effect.
- Auto layout uses the column's content min-width as a floor, so
  shrinking a column below its content width was silently clamped.

Switch back to `table-layout: fixed` for *all* resize-active cases,
and inject a `<colgroup>` populated from `columnWidths` while resize
is active. `<col>` widths are honored by Chromium under fixed layout
even on sticky cells (which inline `<th>` widths are not), so the
resize handle now responds correctly with both pinned and non-pinned
columns and content cannot push columns wider than the user dragged
them.

The colgroup is only rendered when `isResizeActive` is true, so the
non-resize render path is completely unaffected. Verified with the
React handler in a manual integration: drag -100 on the sticky-left
column in the Styles API demo updates Name 285 → 185 and Modified
258 → 358 exactly.
`useColumnResize` only snapshotted column widths when
`isResizeActive` flipped from false to true, then reused that
snapshot indefinitely as the `leftStartWidth` / `rightStartWidth`
baseline for every subsequent drag.

That made the next drag use stale state whenever the rendered widths
no longer matched the recorded `columnWidths` map — most visibly
when the user switched `resizeMode` from `standard` to `finder`
mid-session, since each mode produces a different distribution. The
finder-mode drag would then start from the standard-mode end-state
(not from what the user saw), shrinking 'untouched' columns in the
process.

Snapshot fresh from the DOM at the start of every drag so the math
always matches the visible layout.
The colgroup-driven width override was needed only because Chromium
ignores inline `<th>` widths on `position: sticky` cells under
`table-layout: fixed`. Rendering it unconditionally during resize
slightly altered layout for *non-sticky* resize cases — most
visibly in Finder mode, where the table width and column
redistribution depend on the interplay between cell widths,
`min-width: 100%` on `.table`, and the inline width set by
`getTableStyle`.

Gate the colgroup behind `hasStickyColumns` so the regular resize
path is untouched (matches the pre-shadow-feature behavior exactly)
while pinned tables still get the only fix that makes column resize
actually work for them.
This reverts the recent attempts to make column resize work
alongside sticky columns:

- 08db9dd render resize colgroup only when sticky columns are present
- 0318f6c re-snapshot column widths on every drag, not just the first
- 331028f use <colgroup> for resize widths to fix sticky-column resize
- fe5ef1d switch resize-active table layout from fixed to auto
- a50d8e2 column resize on sticky cells under table-layout: fixed

Each fix solved the sticky case but quietly changed the column-width
distribution in the regular Standard / Finder modes — drags ended up
shrinking neighbours that should have stayed put. Reverting restores
the exact pre-fix resize behavior on non-sticky tables.

Sticky columns + column resize remains a known limitation (Chromium
ignores inline `<th>` widths on `position: sticky` cells under
`table-layout: fixed`); to reflect that, drop `enableColumnResizing`
from the Styles API demo and call it out in the demo's source
comment. The dedicated resize demos (Resize Mode, Auto-Fit Columns,
Configurator) still cover the `resizeHandle` selector.
When neither `scrollProps` nor `withTableBorder` is set, the
`scrollViewport` wrapper had no inline style and rendered as a
default block `<div>`. That introduced an extra layout box between
`.root` and `<table>`, subtly altering how the table's `width:
100%` and `min-width: 100%` resolved against its containing block.
The user-visible side-effect: in resize Finder/Standard modes,
dragging a handle could change neighbouring column widths instead of
keeping them put.

Restore `display: contents` for the no-scroll / no-border case so
the wrapper is invisible to layout and the table relates directly to
`.root` — identical to the rendering that existed before
`scrollProps` was introduced. The wrapper still becomes a real
block when it actually has work to do (`overflow: auto` for scroll,
`clip-path` for rounded-corner clipping).
The Mantine `<Table>` style was built as
`{ ...getTableStyle(), minWidth: scrollProps?.minWidth }`. When
`scrollProps` is not provided, `scrollProps?.minWidth` evaluates to
`undefined`, and the explicit `minWidth: undefined` *overwrites* the
`min-width` that `getTableStyle` had just set for resize mode.

Without that inline `min-width`, the CSS class `.table { min-width:
100% }` won the cascade and forced the table back up to the parent
width on every drag — so the browser proportionally redistributed
the slack across all columns. In Finder mode that looked like
"untouched" columns moving when the user only resized one.

Spread `scrollProps.minWidth` only when it's actually set so the
resize hook's `min-width` survives.
Now that the underlying `min-width` clobber is fixed
(`f54a449`), the sticky-only `<colgroup>` workaround no longer
collides with Finder/Standard mode column distribution. Re-introduce
it (gated on `isResizeActive && hasStickyColumns`) so the resize
handle on a pinned column actually moves the track again — Chromium
ignores inline `<th>` widths on `position: sticky` cells under
`table-layout: fixed`, but it always honors `<col>` widths.

Non-sticky resize keeps the original `<th>` width path unchanged
(no colgroup is rendered) so Finder/Standard column distribution
remains identical to the pre-fix behavior verified earlier.

Re-enable `enableColumnResizing` on the Styles API demo so the
`resizeHandle` selector is exercised again.
@gfazioli gfazioli merged commit c4654e3 into master May 6, 2026
1 check passed
@gfazioli gfazioli deleted the feat/sticky-column-shadow branch May 6, 2026 10:27
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.

feat: shadow effect on pinned columns when horizontally scrolled

2 participants