Skip to content

feat: Table atomic components (extracted from classic Table) - #4977

Draft
gethinwebster wants to merge 29 commits into
mainfrom
dev-v3-gethinw-table-cell-extract
Draft

feat: Table atomic components (extracted from classic Table)#4977
gethinwebster wants to merge 29 commits into
mainfrom
dev-v3-gethinw-table-cell-extract

Conversation

@gethinwebster

@gethinwebster gethinwebster commented Sep 7, 2026

Copy link
Copy Markdown
Member

What

Introduces 7 public Table atomic componentsTableRoot, TableHead, TableHeaderRow, TableHeaderCell, TableBody, TableRow, TableCell — extracted from the existing Table's own cell/row/section elements so the existing Table and the atomics share one substrate.

Approach (extract from the existing Table)

Instead of building atomic parts separately (which diverged from the existing box model — sticky columns and geometry broke), this works backwards: internal element substrates (TableCell/TableHeaderCell/TableRow/TableBody/TableHead) are carved out of the existing Table's td-element/th-element/internal.tsx/thead.tsx, and the existing Table composes those same substrates. The public atomic components layer the R1 API + standalone styling on top. TableRoot's <table> carries too much wiring to carve render-neutrally, so it is public-only for now.

Because the existing Table and the atomics share the substrate, the existing Table is unchanged by construction, and the atomics reuse its proven box model + feature CSS (selection, shading, dividers). Where the atomic public layer must re-emit a class the existing Table computes centrally, it does so via atomic-only hooks (.cell-grid / .header-cell-grid grid markers, data-selected / data-shaded row adjacency) that the existing Table never emits — so the shared CSS is reused, not duplicated, and stays inert for the existing Table.

Verification

  • The existing Table is provably unchanged: byte-identical vs a pre-change 16-state oracle (incl. held sticky horizontal scroll); the two residual non-zero states match the baseline to the pixel and predate this work.
  • Standalone atomics were verified against borderless Table twin pages (an apples-to-apples, chrome-free oracle) built during development and since removed as scaffolding.
  • VR methodology — eyeball, not percentages. Pixel-diff % is unreliable for sparse table UI (a gross column shift or a broken outline flips only ~1–2% of mostly-white pixels), so parity is confirmed by reading full-set side-by-side composites of every demo page against its twin, backed by targeted DPR3 measurements (edge Y, divider thickness, control centering, dynamic toggle deltas). Earlier reliance on the % pipeline produced false-green calls that hid real defects; those were found by eyeball and fixed (see below).
  • Adversarial review: AutoSDE + aspect panel (API/backcompat/a11y/className) — 0 blockers.
  • jest green; the existing Table VR 0% reconfirmed after every change.

Fixes landed from the eyeball audit

  • First-column header offset: atomic header text sat 12px left of the body (the header content wrapper lacked the existing .header-cell-content's inline cell-offset); aligned to body.
  • Selection outline: consecutive selected rows merge into one continuous rounded outline (outer corners only) via data-selected adjacency; the seam is 2px, matching the outer ring.
  • Equal-height cells (auto/grid correctness): the stretch+center treatment is scoped to the grid-mode markers (.cell-grid / .header-cell-grid), so auto-layout tables keep native display:table-cell (an earlier unconditional rule broke auto-layout column sizing) while grid tables get colinear control/data boxes and vertically-centered controls.
  • Striped divider: darkened to the shared $color-border-cell-shaded by reusing the existing Table's body-cell striped rule, triggered via data-shaded row adjacency (dedup intact — the rule exists once in the substrate; only the trigger class was missing).
  • Control-column header divider: restored (the existing Table draws it); the empty single-selection control header no longer collapses (equal-height fix), so the divider renders cleanly with no stray streak.
  • Selection ring repainted as a layout-neutral overlay (zero content shift): the selection outline is now an absolutely-positioned ::after ring on the selected row, and the reused selection border is neutralized to a constant 1px, so real border widths never change between selected/unselected. Grid content-centering is therefore border-insensitive: on select/deselect the cell content, control, row top, and table height are all invariant (measured dy=0, every row 39px) — reproducing the existing Table's zero content movement, which a real-border approach cannot do under grid layout. This retired an earlier constant-height padding hack and the per-state seam border-width juggling.
  • Focus-ring clip on disablePaddings control cells fixed (overflow:visible).

Known minor deltas (documented, not regressions)

  • Auto-layout column-width distribution differs a few px on multi-column pages (atomic columnLayout='auto' is zero-config intrinsic table-layout:auto; grid mode is the opt-in for explicit widths). Accepted.
  • Sorting header widths shift ~7–13px because the atomic renders a custom <button>-based sort header vs the existing native control — accepted.
  • Selected row height: a selected row is 1px shorter than the existing Table's (39 vs 40) — the deliberate constant-height/overlay tradeoff for zero toggle-shift. Selection ring is unchanged (2px); imperceptible.
  • Virtualization (windowed) and loading/empty (composed) are structurally different by design.

Notes

Dev pages: pages/table-root/ (atomic demos). Supersedes the Path-C dedup approach on dev-v3-gethinw-basic-table-mlp.

Draft — pending human bug-bash + publish.

…ment

Introduce src/table/table-cell (internal, not yet exported) holding the pure
body-cell substrate: the td/th element, base .body-cell padding, the
.body-cell-content wrapper, disablePaddings support, and raw-node ref
forwarding. TableTdElement now composes InternalTableCell, keeping all feature
layering (sticky/resize/editable/selection/expandable + compound .body-cell.*
CSS) on its own wrapper. DOM/CSS-identical by construction: same classes, same
child order, feature SCSS unchanged; only an inert .body-cell-content.disable-paddings
rule is added (never applied by classic).
Carve the <tbody> and <thead> element substrates out of classic's render sites into thin internal forwardRef components (src/table/table-body, src/table/table-head), mirroring the Inc1/Inc2/Inc3a pattern. Render-neutral: each substrate renders only the bare element and forwards ref/className/nativeAttributes/children; all feature wiring stays on the composing sites (internal.tsx, thead.tsx). TableRoot's <table> is deferred (performanceMarkAttributes data-* threading is incompatible with the data-*-free substrate contract) -- see evidence FINDINGS/DECISIONS.
…root-classic)

Recreate each pages/table-root atomic demo with the shipped classic Table as a per-scenario VR oracle. Same data (makeItems/makeLine), columns, counts and initial state so the atomic-vs-classic diff is content-identical. Auto-registered at table-root-classic/<name>. Multi-column sort and row virtualization have no classic equivalent (see FINDINGS). Pages only; src untouched.
Set variant="borderless" on the classic Table in every table-root-classic
demo so the classic twin is chrome-free (no outer Container border/radius/
shadow), matching the atomic TableRoot. This is the agreed VR oracle for the
atomic-vs-classic parity work.
Two dominant atomic-vs-classic (borderless) gaps from the VR punch-list:

a1 header row height (29px -> 37px): the public TableHeaderCell rendered its
children with no content box, so the header row was 8px short. Wrap the
children in a .header-cell-content box carrying padding-block:$space-scaled-xxs
(+ line-height:$line-height-body-m), reproducing classic's header-cell-content
vertical sizing. No inline padding, so the v1 is-visual-refresh first-column
inset and the v3 ::after divider are untouched.

a2 edge-row height (first/last body rows 1px taller): TableBody now publishes
each row's first/last position via a RowPositionContext (a no-DOM provider), and
TableCell applies classic's own body-cell-first-row / body-cell-last-row edge
classes. Reusing classic's classes means the 2px transparent placeholder border
and the body-cell-last-row:not(.body-cell-selected) selection guard come for
free -- no reimplementation, no data-* hook, no element/universal selector.

Auto layout verified pixel-exact vs the borderless classic twin (simple page:
header 37, rows 40/39/.../40). Atomic layer only; classic composition (the
src/table substrates) is untouched.
The .body-cell-content truncation clip (overflow:hidden) also applied to
disablePaddings cells, whose content box collapses to the control height.
That cropped the focus ring of a checkbox/radio in a selection-control
column. Opt disable-paddings cells back out of the clip (overflow:visible),
mirroring the existing body-cell-edit-active idiom. Classic Table is
unaffected (disable-paddings is atomic-only). VR unchanged, jest 673/673.
… via data-selected adjacency

TableRow auto-emits data-selected from variant='selected'; the atomic cell module reads sibling adjacency to collapse the shared edge into a single 1px divider, square the inner corners, and (via has-selection + a full-height stretch) enclose the selection-control column in the box. Keyed on data-selected, a hook classic never emits, so classic VR stays 0.0000%.
The simple, striped-rows and single-selection atomic demos have no sort UI, but their classic twins reused sorting-capable column sets. Classic Table renders a sort caret for any column declaring sortingField/sortingComparator (independent of sortingColumn/onSortingChange), so those twins showed carets the atomic lacks. Add non-sorting column sets (dataColumnsPlain, nameStatusColumnsPlain) and point the no-sort twins at them; sorting twins keep the sortable sets.
…assic

The grid columns [{size:40},{minWidth:160},{size:140}] flexed Name to fill and pinned Status to a fixed 140px, shoving Status far right. Classic Table splits the non-control space ~53:47 (measured, proportional across viewports). Reproduce with proportional flex weights [{size:40},{size:{flex:53}},{size:{flex:47}}] so atomic per-column widths match classic to the pixel.
Point the classic loading-and-empty VR oracle at dataColumnsPlain (no
sortingField/sortingComparator) so it renders no sort carets, matching the
non-sortable atomic demo. Removes the stray down-carets on all 4 headers and
converges column widths to the atomic (was Size -26.5px).
The header column-divider ::after painted a 1px divider (border-inline-start,
color-border-divider-default) on disablePaddings header cells (the selection
control column). Classic Table draws no divider there. Add
.header-cell.disable-paddings::after { display: none }. Classic never emits
disable-paddings, so this is inert on the classic path (selection-single VR
0.0000% vs oracle).
…header divider, and equal-height cells

Standalone visual-parity fixes surfaced by eyeballing the demo pages
against borderless classic Table twins (percentages proved unreliable for
sparse table UI):

- Equal-height cells: move the stretch+center treatment onto the grid-mode
  markers (.cell-grid / .header-cell-grid) instead of the unconditional
  .cell, so auto-layout tables keep native display:table-cell (fixes the
  collapsed simple/auto layout) while grid tables get colinear control/data
  boxes and centered controls.
- Consecutive-selected seam: set the shared bottom edge to the 1px list
  divider (mirroring classic body-cell-next-selected) so the two-row seam
  paints 2px like classic, not 1px.
- Control-column header divider: remove the erroneous
  .header-cell.disable-paddings::after { display:none } suppression -
  mainline classic DOES draw this divider. The empty single-selection
  control header no longer collapses (equal-height fix), so the restored
  divider renders full-cell like classic with no stray streak.

All keyed on atomic-only selectors classic never emits; classic Table VR
byte-identical (0%), jest green.
An isolated selected row was +1px taller (40px) than unselected (39px):
the selection border grows +2px total while classic's body-cell-selected
reclaim returns only 1px. In grid mode each row is its own grid-auto-rows
track sized by cell border-box, so that +1px grew the track and cascaded
every row below by 1px on each select/deselect, resizing the table.

Consecutive-run rows already net 39px via the seam/placeholder-start rules,
so only the isolated selected state leaked. Absorb its surplus pixel into
the selected cell's own padding-block-end, grid-mode only:

  [data-selected]:not([data-selected] + [data-selected]):not(:has(+ [data-selected])) > .cell-grid { padding-block-end: 0 }

Result: every selection state is 39px; select/deselect causes zero row-height
change, zero table resize, zero cascade (measured, both selection pages).
2px selection border, merged-pair outline, and control centering unchanged.
Grid-scoped, so auto-layout tables and classic (never emit .cell-grid) are
inert. Constant-height chosen over classic's neighbor-compensation (user
decision) since it eliminates all movement, including classic's own 1px nudge.
…zero content shift)

Replace the real-border selection outline with an absolutely-positioned ::after
ring on the selected row, and neutralize the reused body-cell selection border to
a constant 1px so real border widths never change between selected/unselected.
Grid content-centering is then border-insensitive: on select/deselect the cell
content, control, row top, and table height are all invariant (measured dy=0,
every row 39px), matching classic's zero content movement — which the real-border
approach could not do under grid layout.

- reset the selected cell's content-wrapper bottom padding to the unselected value
  (classic's reclaim compensated for a real border the atomic no longer has),
  removing the last 0.5px text nudge;
- draw the consecutive-selected seam from the first-of-pair ring's own 2px bottom
  edge (matches the outer ring width) instead of a 1px tinted divider;
- suppress (transparent, width unchanged) the unselected-above row's divider so no
  grey line abuts the ring top, matching classic.

Retires the constant-height padding-block-end:0 hack and per-state seam
border-width juggling. Atomic-only hooks (data-selected, .cell-grid); classic-inert.
…assic' wording

Behaviour-neutral cleanup of the 7 atomic components:
- Trim multi-paragraph comments to concise WHY/gotchas; remove comments that
  restate the code or narrate edit history (row-px values, 'earlier fix', etc.).
- Remove a dead `styles['disable-paddings']` class from the body <td> (no such
  rule exists in the table-cell module; disablePaddings is handled by the
  substrate on the inner content wrapper) — a remnant of the removed per-state
  control-cell padding compensation.
- Replace ambiguous 'classic'/'classic Table' wording with 'the existing Table'
  ('classic' conventionally denotes the classic theme). Reused hashed class
  names, import paths, and the is-visual-refresh theme token are unchanged.

No functional or CSS-selector changes. jest 37/37, gulp quick-build, and
stylelint all green.
These borderless-Table reference pages were scaffolding for the visual-parity
work (an apples-to-apples oracle for eyeball comparison), not part of the
shipped component. Removing them now that parity is established. Pages are
auto-discovered, and nothing outside the directory referenced it.
…y->ies finder derivation

Adding the 7 public atomic components regenerated three global snapshots
(documenter, test-utils-wrappers, test-utils-selectors) — additive only
(the new component definitions + their generated finders).

Also fixes a latent bug in the shared functional-tests/test-utils.test.tsx
helper: it derived the expected findAll finder name by regex-scanning the
wrapper prototype for `findAll<Pascal>.*`, which fails when the plural is not
singular+suffix. TableBody is the first component whose finder is pluralized
y->ies (findAllTableBodies), so the naive match resolved to undefined and threw
`wrapper[findAllName] is not a function`. Derive the name from the same
build-tools/utils/pluralize helper the generator uses, so the test can never
drift from the generated finder names; drop the now-unused DomElementWrapper import.

Full snapshot + functional + table-root suites: 10 suites / 730 tests / 107
snapshots green.
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.51923% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.67%. Comparing base (0c1c9fd) to head (7fc7d15).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
src/table-root/internal.tsx 88.88% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #4977    +/-   ##
========================================
  Coverage   97.66%   97.67%            
========================================
  Files         959      990    +31     
  Lines       31345    31556   +211     
  Branches    11579    11630    +51     
========================================
+ Hits        30614    30823   +209     
+ Misses        724      687    -37     
- Partials        7       46    +39     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

1 participant