Skip to content

Table: stop shifting every column when a table gains a scrollbar - #1210

Draft
JeanMarcMilletScality wants to merge 3 commits into
development/1.0from
improvement/CUI-table-head-scrollbar-gutter
Draft

JeanMarcMilletScality wants to merge 3 commits into
development/1.0from
improvement/CUI-table-head-scrollbar-gutter

Conversation

@JeanMarcMilletScality

Copy link
Copy Markdown
Contributor

TL;DR — Table: every column in the header jumped sideways the moment a table grew long enough to scroll; the header and the body now reserve the scrollbar's space up front, so nothing moves.

Context / Why

The head row sits outside the body's scroll container, so its tracks come out wider than the body's by the width of the scrollbar. That was closed by measuring the bar in JavaScript and subtracting it from the header's width. The arithmetic was correct and nothing looked misaligned — this PR is about the mechanism, which had four failure modes and needed a hundred lines of props, state and a callback ref to hold up.

🧩 Approach

Reserve the same gutter on both elements and let the engine supply the width.

HeadRow      width: calc(100% - 4px - <measured>px) !important   →   scrollbar-gutter: stable
body scroller  (nothing)                                          →   scrollbar-gutter: stable

stable rather than auto on the head row because that element is overflow: hidden and so never shows a bar of its own; auto would reserve nothing there. Measured on a live page with the library's own global scrollbar styling in effect, using 200px probes and reading back clientWidth:

Probe Reserved
overflow-y: auto, scrollable — what the body scroller does today 11px
overflow-y: hidden + scrollbar-gutter: stable — what the head row now does 11px
overflow-y: auto + stable, content not overflowing 11px
overflow-y: hidden, no gutter — the head row before this change 0px

The first three agreeing is the fix: header and body reserve the same width, and they keep reserving it when the table is too short to scroll. Two things the table no longer has to know — the number is the engine's own, so it follows a thin bar (11px), a consumer's auto (15px) or none (0px) with no code change; and it is per-element, so a themed or zoomed bar cannot leave a stored value stale.

Moving off the JavaScript measurement was the point rather than a side effect: it shifted every column the instant a table gained a scrollbar, which is the same reflow that padding-right was rejected for in #1196, in the same release. The probe also measured a synthetic overflow: scroll div rather than the table's own scroller, agreeing only for as long as both resolved to the same rules; it ran from a dependency-free callback ref, so zoom, a theme with different scrollbar rules or a switch to overlay bars left the stored number stale; and !important on a runtime pixel value put the header's width beyond the reach of any consumer stylesheet.

The visible trade is the one already accepted for the form scroll gutter in #1196: a table short enough not to scroll now reserves the gutter anyway, instead of reflowing when it crosses into scrollable.

🔧 Usage

⚠️ Breaking for deep imports. HeadRow and useTableScrollbar are reachable through the package's ./dist/* subpath, so their shapes are public API even though the table renders them itself. Both call sites in this repo, before and after:

// before
const { hasScrollbar, scrollBarWidth, handleScrollbarWidth } = useTableScrollbar();   // ←

<HeadRow
  $hasScrollBar={hasScrollbar}                                                        // ←
  $scrollBarWidth={scrollBarWidth}                                                    // ←
  $rowHeight={rowHeight}
/>
<TableBody role="rowgroup" className="tbody" ref={handleScrollbarWidth}>              // ←

// after
<HeadRow $rowHeight={rowHeight} />                                                    // ←
<TableBody role="rowgroup" className="tbody">                                         // ←

hasScrollbar and setHasScrollbar stay on the table context. Nothing reads hasScrollbar now, but removing it would be an API decision rather than cleanup, so it is left alone.

🔍 Review focus

  • 🔴 Critical — tablev2/Tablestyle.tsx › HeadRow and tablev2/TableCommon.tsx › useTableScrollbar — two props and two hook return fields are removed. A consumer deep-importing either gets a type error at build (the good case) or, if it renders HeadRow from JavaScript, a header that silently stops compensating. Worth a search of consumer code before this merges.
  • 🟡 Moderate — tablev2/TableCommon.tsx › SmoothScrollDiv — the gutter is applied via the inline style prop alongside scrollBehavior, so a consumer passing their own style cannot override it and cannot lose it either. Check that is the intended precedence.
  • ⚪ Minor — both selectable-content components — a useTableScrollbar() call left over with nothing reading its result; the one call site that reports scrollability lives in TableRows.

🧪 How to test

  1. Open any tablev2 story in Storybook with a scrollable body.
  2. Note where the header's column boundaries sit against the first row's cells — they should agree.
  3. Reduce the data until the table no longer scrolls, and confirm the header does not jump sideways. Before this change it moved by the bar's width at that exact threshold.
  4. On the short, non-scrolling table, confirm the gutter is still reserved: the last column ends short of the right edge by the bar's width, and the header and body agree.
  5. Scroll a long table horizontally and vertically and confirm no gutter appears along the bottom edge.

Follow-up

  • Step 3 and step 4 are the visual pass, and it has not been run — both browser bridges were unavailable in the session that opened this PR. The reserve itself is measured (the table above); what is unconfirmed is how a short table now looks with a gutter it did not previously reserve.

🔗 References

What changed

useTableScrollbar keeps its name and its context passthrough but no longer measures anything: the probe div it injected, read and removed is gone with the two fields it fed. The head row's width drops its !important and is now calc(100% - 4px) unconditionally, where the 4px is the row's own border.


🤖 Generated with Claude Code

…ng it

The head row sits outside the body's scroll container, so its tracks came
out wider than the body's by the width of the scrollbar. That was closed
by measuring the bar in JS -- injecting a throwaway overflow:scroll div,
reading offsetWidth minus clientWidth -- and subtracting the result from
the head row's width with !important.

The arithmetic was right, so nothing looked wrong. The mechanism was the
problem. Every column shifted sideways the moment a table gained its
first scrollbar, which is the reflow the form's scroll gutter rejected
padding-right to avoid. The probe measured a synthetic element rather
than the table's own scroller, so it agreed only while both resolved to
the same rules. It ran from a callback ref with no dependencies, so
anything that changed the bar's width afterwards -- zoom, a theme with
different scrollbar rules, the platform switching to overlay bars --
left the stored number stale. And !important put the head row's width
beyond the reach of any consumer.

Reserve the same gutter on both elements instead. The head row needs
`stable` rather than `auto` because it is overflow hidden and so never
shows a bar of its own. This removes useTableScrollbar's measurement,
both props on HeadRow, and the !important.

The visible trade is the one already accepted for the form: a table
short enough not to scroll now reserves the gutter anyway, rather than
reflowing when it crosses into scrollable.

hasScrollbar stays on the table context. Nothing reads it now, but it is
reachable through a supported deep import, so removing it is an API
decision rather than cleanup.
…ow lists

Both selectable-content components still destructured setHasScrollbar
from useTableScrollbar and never used it: the one call site that reports
scrollability lives in TableRows. Nothing read the value here even
before the gutter change.
…ead row

The comment explaining why the gutter is stable rather than auto keeps
its why; the sentence describing the measurement approach it replaced
belongs in the PR body, not beside the CSS.
@bert-e

bert-e commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Hello jeanmarcmilletscality,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval ⭐
/bypass_build_status Bypass the build and test status ⭐
/bypass_commit_size Bypass the check on the size of the changeset TBA ⭐
/bypass_incompatible_branch Bypass the check on the source branch prefix ⭐
/bypass_jira_check Bypass the Jira issue check ⭐
/bypass_peer_approval Bypass the pull request peers' approval ⭐
/bypass_leader_approval Bypass the pull request leaders' approval ⭐
/bypass_source_branch_lineage Bypass the cross-branch contamination check ⭐
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request.
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

@bert-e

bert-e commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

This branch has not been deployed

No deployments
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.

2 participants