Skip to content

fix(tablev2): keep the sort caret out of the header's intrinsic width - #1180

Merged
bert-e merged 2 commits into
development/1.0from
bugfix/CUI-sort-caret-header-width
Aug 11, 2026
Merged

fix(tablev2): keep the sort caret out of the header's intrinsic width#1180
bert-e merged 2 commits into
development/1.0from
bugfix/CUI-sort-caret-header-width

Conversation

@JeanMarcMilletScality

@JeanMarcMilletScality JeanMarcMilletScality commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

TL;DR — Sortable Tablev2 headers have been 20px wider than they should be since 0.226.0, which knocks the header row out of alignment with the body rows below it. The sort caret no longer takes part in that width calculation.

Context / Why

#1168 gave the sort caret a real, in-flow width so the hover-reveal caret would stop overlapping the header label. It fixed the overlap, but it also made every sortable header 20px wider at its narrowest — so header columns and body columns stop agreeing on widths, and right-aligned headers sit short of the values underneath them. Reported on the ARTESCA Certificates page, where the Expire On header visibly fails to line up with its dates.

🧩 Approach

The caret is still an in-flow flex item — it has to be, or it can't sit next to the label — but it is now zero-width, and HeaderContent reserves the room it needs with padding instead.

Beforemin-width: 16px + margin-left: 4px means the caret contributes 20px to the header's min-content, which the body cell below it doesn't have:

export const SortCaretWrapper = styled.span`
  position: relative;
  display: inline-flex;
  flex: none;
  align-items: center;
  justify-content: center;
  min-width: ${spacing.r16};   // ←
  margin-left: ${spacing.r4};  // ←
`;

After — a zero-width anchor holding an out-of-flow glyph, so the caret contributes nothing to min-content:

export const SortCaretWrapper = styled.span`
  position: relative;
  flex: none;
  width: 0;              // ←
  align-self: stretch;
`;

export const SortCaretGlyph = styled.span`
  position: absolute;    // ←
  top: 0;
  bottom: 0;
  width: ${caretGlyphSize};
  display: inline-flex;
  align-items: center;
  justify-content: center;
`;

Which side HeaderContent reserves follows the column's alignment, because the label has to stay flush with whichever edge its values are flush with:

textAlign Padding reserved Caret side
right / end padding-inline-start Start of the label (order: -1)
center padding-inline (both) End of the label, label stays centred
default / left padding-inline-end End of the label
(not sortable) none

The caret-before-label case is the one that looks unusual, so worth noting it's the convention: MUI's TableCell sets flex-direction: row-reverse for align: 'right' only — no other alignment touches flex-direction — and TableSortLabel inherits it while rendering label-then-icon, with the same 4px gutter. Their issue #21085 asking for an explicit position prop was closed not planned. We use order: -1 rather than row-reverse because HeaderContent computes justify-content explicitly and row-reverse would invert what flex-start/flex-end mean.

Verified in Chrome at 1280×900 against a reverted baseline (jsdom doesn't lay out, so this can't be a Jest assertion):

Before After
Two identical-flex columns, one sortable one not, in a 220px container header widths 97 / 91 — neither matches the 94px body cell 94 / 94 — both match
Right-aligned sortable header, text right edge vs. the 1198px content edge 1181 — 17px short 1198 — flush
Caret glyph distance from the label 3px, inside the header, in every alignment
Hovering to reveal the caret no layout shift no layout shift#1168's actual goal still holds

Headers also get min-width: 0 and an ellipsizing HeaderLabel, so a long header truncates rather than becoming the reason a column outgrows its cells.

This does not fully fix the reported Certificates symptom. Only ~17px of that ~160px gap was the caret. The rest is a separate, pre-existing defect: body cells ignore textAlign entirely, because the default string Cell wraps values in ConstrainedText, whose container is display: -webkit-box and doesn't honour text-align. That needs its own ticket and its own PR.

📷 Screenshots

🔍 Review focus

  • 🟡 Moderatesrc/lib/components/tablev2/Tablestyle.tsx › HeaderContent — the alignment→padding mapping and the order: -1 flip apply to every Tablev2 header. Worth checking the centred case in particular: it reserves 20px on both sides unconditionally, which costs a centred sortable column 40px of label space whether or not it's currently sorted.
  • 🟡 Moderatesrc/lib/components/tablev2/Tablestyle.tsx › HeaderLabel, TableHeadermin-width: 0 plus text-overflow: ellipsis is a behaviour change for callers who pass no new prop: a header that used to force its column wider now truncates instead. Intended, but it's the part most likely to surprise a consumer.
  • Minorsrc/lib/components/tablev2/MultiSelectableContent.tsxHeaderLabel is deliberately not applied to the selection branch, so the checkbox keeps its click-handling div without inheriting the ellipsis clipping.

🧪 How to test

  1. npm run storybook, then open Components / Data Display / Table → Sort Caret Header Alignment.
  2. Section (A): the two Temperature columns are identically sized and differ only in disableSortBy. Both headers should be exactly as wide as, and start at the same x as, the cells beneath them. Before this change the sortable one was 6px wider.
  3. Section (B): the right-aligned Expire On header text should end flush with the column's right content edge, with the caret to its left. (The dates below it will still be left-aligned — that's the separate ConstrainedText defect above, not this change.) The centred Status header should stay centred.
  4. Hover each sortable header — the caret appears and nothing shifts.
  5. Section (C): same columns through MultiSelectableContent. Note the checkbox column still causes a 12–16px header/body disagreement; that is pre-existing and unchanged by this PR.
  6. Regression check on any existing table story (Tablev2 → the standard examples) — headers should look untouched.

🔗 References

  • #1168fix(Tablev2): reserve layout space for the sort caret, the change this regression came from. Its goal (no layout shift on hover-reveal) is preserved.
  • mui/material-ui#21085 — MUI's request for an explicit sort-icon position prop, closed not planned; the precedent for deriving the caret side from the column alignment.
What changed

Tablestyle.tsx carries the fix: SortCaretWrapper becomes a zero-width anchor, the painted glyph moves into a new out-of-flow SortCaretGlyph, HeaderContent gains a $sortable flag and the alignment-driven padding, and a new HeaderLabel handles truncation.

SingleSelectableContent.tsx and MultiSelectableContent.tsx only pass $sortable and wrap the rendered header in HeaderLabel.

stories/tablev2.stories.tsx adds the repro story used for the measurements above. The container widths in it matter: at 400px the caret's 20px floor never binds, so a broken build measures as passing — section (A) uses 220px deliberately.

No public API change: no new props, no changed prop types or defaults.

JeanMarcMilletScality and others added 2 commits August 11, 2026 14:34
… (CUI)

0.226.0 gave the sort caret a real 20px flex item so the hover-reveal
`SortIncentive` would stop overlapping the label. That fixed the overlap but
lifted every sortable header's min-content 20px above the matching body cell's,
so as soon as that floor binds the header row and the body row stop agreeing on
column widths — and right-aligned headers sit ~20px short of their values.

The caret is now a zero-width flex item (`SortCaretWrapper`) holding an
out-of-flow `SortCaretGlyph`, and `HeaderContent` reserves the room with
`padding-inline-*` instead. Being a real flex item is what keeps the glyph
beside the label; being zero-width is what keeps it out of the sizing equation.
Anchoring the glyph to `HeaderContent`'s edge was tried first and stranded it at
the far side of a wide column.

Which side gets the padding follows the alignment: end-aligned columns reserve
the start side and move the caret there with `order: -1`, since the label has to
stay flush with the trailing edge to line up with the values below it; centred
columns reserve both sides so the label stays centred; everything else reserves
the end side. Non-sortable columns reserve nothing. MUI resolves this the same
way — `TableCell` sets `flex-direction: row-reverse` for `align: 'right'` only,
and `TableSortLabel` inherits it while rendering label-then-icon.

Headers also get `min-width: 0` plus an ellipsizing `HeaderLabel`, so a long
header truncates rather than becoming the reason a column is wider than its
cells.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jsdom does not lay out, so the widths and alignments this guards can only be
checked in a real browser. Three sections: two identical-flex columns (one
sortable, one not) in a 220px container, which is narrow enough that the caret's
floor actually binds; a certificate table with a centred and a right-aligned
column; and the same columns through `MultiSelectableContent`.

Note the container width matters — at 400px the floor never binds and a broken
build measures as passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bert-e

bert-e commented Aug 11, 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
/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 Aug 11, 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:

@JeanMarcMilletScality

Copy link
Copy Markdown
Contributor Author

/approve

@bert-e

bert-e commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

I have successfully merged the changeset of this pull request
into targetted development branches:

  • ✔️ development/1.0

Please check the status of the associated issue None.

Goodbye jeanmarcmilletscality.

The following options are set: approve

@bert-e
bert-e merged commit ba101d0 into development/1.0 Aug 11, 2026
8 checks passed
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.

3 participants