Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f005d9e
fix: adjust layout-calculating heuristics
5ZYSZ3K Aug 12, 2026
ff5b21e
fix(heuristic-table-plugin): add colgrups and redistribute widths
5ZYSZ3K Sep 3, 2026
3b0e359
feat(heuristic-table-plugin): make heuristic table respect external p…
5ZYSZ3K Sep 3, 2026
2eb8ec5
feat(heuristic-table-plugin): enable configuring font coeffs for tables
5ZYSZ3K Sep 8, 2026
4237ad3
feat(heuristic-table-plugin): implement border collapsing and stick t…
5ZYSZ3K Sep 9, 2026
6c3b7ba
feat(heuristic-table-plugin): add default padding
5ZYSZ3K Sep 10, 2026
c9c70e9
feat(heuristic-table-plugin): resolve cell styles once and lay out ag…
5ZYSZ3K Sep 15, 2026
7359ad5
fix(heuristic-table-plugin): assess tests and adjust nested table ren…
5ZYSZ3K Sep 15, 2026
7247c6a
feat(heuristic-table-plugin): hide height growts behind a prop
5ZYSZ3K Sep 15, 2026
b09711d
fix(heuristic-table-plugin): horizontal and vertical paddings adjuste…
5ZYSZ3K Sep 15, 2026
b506394
fix(heuristic-table-plugin): adjust tests
5ZYSZ3K Sep 15, 2026
da969db
fix(heuristic-table-plugin): width resolution, surplus sharing and lo…
5ZYSZ3K Sep 15, 2026
a70870e
fix(heuristic-table-plugin): fill table height, preserve row heights,…
5ZYSZ3K Sep 15, 2026
2bdeca0
fix(heuristic-table-plugin): scroll fixed-height tables without clipp…
5ZYSZ3K Sep 15, 2026
322073e
fix(heuristic-table-plugin): stop requiring contentWidth in config an…
5ZYSZ3K Sep 15, 2026
fb65684
refactor(heuristic-table-plugin): share layout helpers and drop the r…
5ZYSZ3K Sep 15, 2026
c048d04
fix(heuristic-table-plugin): render stray cells without crashing and …
5ZYSZ3K Sep 15, 2026
621678e
refactor(heuristic-table-plugin): separate the table grid from config…
5ZYSZ3K Sep 15, 2026
779d3b4
refactor(heuristic-table-plugin): split oversized modules and name th…
5ZYSZ3K Sep 15, 2026
6e6257b
fix(heuristic-table-plugin): respect table direction when collapsing …
5ZYSZ3K Sep 15, 2026
2b05ff3
feat(heuristic-table-plugin): add comparison example to the app
5ZYSZ3K Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(heuristic-table-plugin): width resolution, surplus sharing and lo…
…gical padding shorthands
  • Loading branch information
5ZYSZ3K committed Sep 15, 2026
commit da969db81f742d70e0909d37c891a36a4c21df7a
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [@native-html/heuristic-table-plugin](./heuristic-table-plugin.md) &gt; [HeuristicTablePluginConfig](./heuristic-table-plugin.heuristictablepluginconfig.md) &gt; [growBeyondHeight](./heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md)

## HeuristicTablePluginConfig.growBeyondHeight property

When true, an explicit `height` on the table, or on any of its cells, is treated as a minimum: the box still grows to fit content taller than it. When false, that `height` is enforced as written and taller content overflows it.

**Signature:**

```typescript
growBeyondHeight?: boolean;
```

## Remarks

Per [CSS 2.1 §17.5.3](https://www.w3.org/TR/CSS21/tables.html#height-layout)<!-- -->, `height` on a `table`<!-- -->, `tr`<!-- -->, `th` or `td` box is only a minimum, so `true` is the faithful reading of the HTML. It is off by default because React Native has no table layout algorithm to shrink a row back down, and a document whose markup sizes its tables is better served by a box that stays the size it asked for.

Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,25 @@ boolean
_(Optional)_ When true, the table stretches to fill the width its containing block offers — `contentWidth`<!-- -->, less the horizontal spacing of every ancestor. When false, a table with an auto width shrinks to fit its content.


</td></tr>
<tr><td>

[growBeyondHeight?](./heuristic-table-plugin.heuristictablepluginconfig.growbeyondheight.md)


</td><td>


</td><td>

boolean


</td><td>

_(Optional)_ When true, an explicit `height` on the table, or on any of its cells, is treated as a minimum: the box still grows to fit content taller than it. When false, that `height` is enforced as written and taller content overflows it.


</td></tr>
</tbody></table>

Expand Down
12 changes: 9 additions & 3 deletions packages/heuristic-table-plugin/src/TableLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,17 @@ export default class TableLayout {
0,
containingWidth - getHorizontalMargins(style)
);
// A percentage table width resolves against the containing block, whereas
// the columns are laid out inside the table's own padding and border.
// Percentages resolve against the width the table may actually occupy,
// margins already deducted, rather than against the whole containing
// block. Resolving `width:100%` against the latter would hand the columns
// more width than the table box is allowed — by exactly the margins — and
// the surplus would then be shown through a horizontal scroller the same
// table without a declared width never gets. An absolute width is
// untouched by this and still overflows into that scroller when it does
// not fit, as it should.
const { width, minWidth, maxWidth } = resolveWidthConstraints(
tnode,
containingWidth
availableWidth
);
const declaredTableWidth =
width === null ? null : clampWidth(width, minWidth, maxWidth);
Expand Down
26 changes: 26 additions & 0 deletions packages/heuristic-table-plugin/src/__tests__/TableLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,32 @@ describe('TableLayout', () => {
expect(totalWidth).toBeCloseTo(378);
});

it('should resolve a percentage table width inside its own margins', () => {
// A percentage may not resolve against width the margins have already
// spent: `width:100%` would then hand the columns the whole containing
// block while the table box is allowed only what is left of it, and the
// difference would surface as a scroller the very same table without a
// declared width never gets.
const { totalWidth, assignableWidth } = layoutFor(
`<table style="width: 100%; margin: 0 10px">${rows}</table>`,
{ contentWidth: 400, forceStretch: true }
);
expect(assignableWidth).toBe(380);
expect(totalWidth).toBeCloseTo(380);
expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(false);
});

it('should still scroll an absolute width wider than the container', () => {
// Clamping the columns to the available width instead would silently
// drop the width the table asked for.
const { totalWidth, assignableWidth } = layoutFor(
`<table style="width: 800px">${rows}</table>`,
{ contentWidth: 400 }
);
expect(totalWidth).toBeCloseTo(800);
expect(shouldScrollTable(totalWidth, assignableWidth)).toBe(true);
});

it('should take the table own margins out of the width it may occupy', () => {
const { assignableWidth, availableWidth } = layoutFor(
`<table style="margin: 25px">${rows}</table>`,
Expand Down
39 changes: 39 additions & 0 deletions packages/heuristic-table-plugin/src/__tests__/layoutStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,31 @@ describe('layout and resolved renderer styles', () => {
expect(layout.totalWidth).toBeCloseTo(29.1);
});

it('gives identical collapsed cells identical content width', () => {
// Each cell draws its own trailing boundary while the wrapper draws the
// outer ones, so the cell in the last column carries one border fewer than
// its neighbours and its column is narrower by exactly that border. Never
// by more: the surplus is shared over content, so a bookkeeping difference
// in who paints a boundary cannot become a difference in content width.
const td = '<td style="border:2px solid black;padding:0">A</td>';
const layout = layoutFor(
`<table style="border-collapse:collapse"><tr>${td}${td}${td}${td}</tr></table>`,
{ contentWidth: 100, forceStretch: true }
);
const [first, second, third, last] = layout.columnWidths;
expect(layout.horizontalInsets).toBe(4);
expect(second).toBeCloseTo(first!);
expect(third).toBeCloseTo(first!);
expect(last).toBeCloseTo(first! - 2);
expect(layout.totalWidth).toBeCloseTo(96);
});

it.each([
[{ padding: 8 }, 16],
[{ paddingHorizontal: 8 }, 16],
[{ paddingInline: 8 }, 16],
// Vertical padding leaves the horizontal sides to the user-agent default.
[{ paddingBlock: 8 }, 2],
[{ paddingLeft: 8 }, 9],
[{ padding: 0 }, 0],
[{ paddingStart: 8 }, 8],
Expand Down Expand Up @@ -288,6 +310,23 @@ describe('layout and resolved renderer styles', () => {
});
});

it('lets a callback logical padding shorthand override source longhands', () => {
// Yoga resolves `paddingInline` after the per-side edges, so a source
// `padding-left` would otherwise hold that one side and leave the callback
// shorthand painting only the other three.
const layout = layoutFor(
'<table><tr><td style="padding-left:4px">A</td></tr></table>',
{
getStyleForCell: () => ({ paddingInline: 8 })
}
);
expect(layout.totalWidth).toBeCloseTo(9.1 + 8 + 8);
expect(renderedCellStyle(layout, 0)).toMatchObject({
paddingLeft: 8,
paddingRight: 8
});
});

it('uses callback borders for both outer and neighbouring collapsed edges', () => {
const getStyleForCell = jest.fn((cell) =>
cell.x === 1 ? { borderWidth: 5, borderColor: 'red' } : null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export default class TCellConstraintsComputer {
);
return {
...(percentWidth === null ? {} : { percentWidth }),
horizontalSpace: stats.horizontalSpace,
minWidth,
// `max-width` caps the width the cell would *like*, but never takes it
// below the width it needs to hold its longest word: min-content is a
Expand Down
38 changes: 35 additions & 3 deletions packages/heuristic-table-plugin/src/helpers/computeColumnWidths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,24 @@ function normalizePercentages(
* of them can take is left unassigned: a table whose every column is capped
* ends up narrower than the width it was given, as it would in CSS, rather
* than pushing a column past the ceiling it declared.
*
* The surplus is shared in proportion to how much *content* each column
* already holds, not to its whole border box, so that spacing a column
* happens to carry cannot earn it content width. It matters under the
* collapsing border model, where each cell owns a different subset of the
* boundaries around it — the cell in the last column has its trailing border
* painted by the table wrapper rather than by itself — and weighting by the
* border box would compound that difference instead of preserving it.
*
* @param insets - Each column's horizontal padding and border, which is held
* out of the weighting. Defaults to none.
*/
function addDistributedWidth(
widths: number[],
total: number,
indexes: number[],
caps: Array<number | null>
caps: Array<number | null>,
insets: number[] = []
): number[] {
if (indexes.length === 0 || total <= 0) {
return widths;
Expand All @@ -100,7 +112,11 @@ function addDistributedWidth(
while (remaining > EPSILON && candidates.length > 0) {
const shares = distribute(
remaining,
candidates.map((i) => result[i] ?? 0)
// A column holding nothing but its own spacing weighs nothing, and so
// waits while the columns with content grow. It is not stranded: once
// they have all reached their caps it is the only candidate left, and
// `distribute` shares evenly when every weight is zero.
candidates.map((i) => Math.max(0, (result[i] ?? 0) - (insets[i] ?? 0)))
);
let consumed = 0;
candidates.forEach((i, k) => {
Expand Down Expand Up @@ -247,6 +263,22 @@ export default function computeColumnWidths(
if (!shouldStretch) {
return maxContentGuess;
}
// The spacing each column carries, so that the surplus below is shared over
// content alone. A `colspan` spreads its own spacing across the columns it
// covers, as it does its intrinsic constraints, and where cells disagree the
// widest wins — the same reduction `minWidth` gets, which is the figure the
// spacing is being held out of.
const columnInsets = columnConstraints.map(() => 0);
for (const cell of display.cells) {
const share = (cell.constraints.horizontalSpace ?? 0) / cell.lenX;
const lastColumn = Math.min(
cell.x + cell.lenX - 1,
columnInsets.length - 1
);
for (let i = cell.x; i <= lastColumn; i++) {
columnInsets[i] = Math.max(columnInsets[i] ?? 0, share);
}
}
const allColumns = maxContentGuess.map((_, i) => i);
// A column that declared a width of its own already has the width it asked
// for; the surplus belongs to the ones that left it to the table to decide.
Expand All @@ -266,7 +298,7 @@ export default function computeColumnWidths(
if (leftover <= EPSILON) {
break;
}
widths = addDistributedWidth(widths, leftover, group, caps);
widths = addDistributedWidth(widths, leftover, group, caps, columnInsets);
}
return widths;
}
28 changes: 6 additions & 22 deletions packages/heuristic-table-plugin/src/helpers/fillTableDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import TCellConstraintsComputer from './TCellConstraintsComputer';
* @remarks
* {@link fillTableDisplay} may be called without a computer, to lay the grid
* out before the width its cells must be measured against is known. Every cell
* of such a display carries this placeholder until {@link measureDisplay}
* replaces it.
* of such a display carries this placeholder until the caller's measurement
* pass replaces it.
*/
const UNMEASURED_CONSTRAINTS: TCellConstraints = Object.freeze({
contentDensity: 0,
Expand Down Expand Up @@ -86,8 +86,10 @@ function findFreeSlotX(display: Display, fromX: number, y: number): number {
* @param computer - Measures each cell as it is laid down. Omit it to build
* the grid alone — coordinates and spans do not depend on the width the table
* resolves to, whereas constraints do, and measuring text is the costly half
* of a layout pass. Pass the display to {@link measureDisplay} once that width
* is known.
* of a layout pass. Measure the cells once that width is known, as
* {@link TableLayout} does: the collapsing border model has to resolve the
* table's own borders — from cell coordinates alone — before the width those
* cells are measured against exists.
*/
export default function fillTableDisplay(
tnode: TNode,
Expand Down Expand Up @@ -138,21 +140,3 @@ export default function fillTableDisplay(
);
}
}

/**
* Measure every cell of an already laid out display.
*
* @remarks
* The counterpart to calling {@link fillTableDisplay} without a computer: the
* collapsing border model has to resolve the table's own borders — from cell
* coordinates alone — before the width those cells are measured against
* exists. Splitting the two keeps the grid walked once either way.
*/
export function measureDisplay(
display: Display,
computer: TCellConstraintsComputer
) {
for (const cell of display.cells) {
cell.constraints = computer.computeCellConstraints(cell.tnode);
}
}
21 changes: 18 additions & 3 deletions packages/heuristic-table-plugin/src/helpers/tableStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,28 @@ export function getDefaultCellPaddingStyle(
return resolvedStyle;
}

/** Expand callback shorthands so resolved source longhands cannot mask them. */
/**
* Expand callback shorthands so resolved source longhands cannot mask them.
*
* @remarks
* Every shorthand Yoga resolves *after* a per-side edge has to be expanded
* here, the logical `paddingInline` / `paddingBlock` pair included: a cell
* declaring `padding-left` in its source CSS reaches the merge as a longhand,
* which would otherwise win on that one side and leave the callback's
* shorthand painting the other three — the opposite of the documented rule
* that callback padding replaces source padding outright.
*
* The per-side logical properties (`paddingStart`, `paddingInlineEnd` and
* friends) need no expansion: Yoga already resolves them ahead of the physical
* longhands, so they mask the source rather than being masked by it.
*/
export function resolveConfiguredCellStyle(
style: ViewStyle | null | undefined
): ViewStyle | null {
if (!style) return null;
const horizontal = style.paddingHorizontal ?? style.padding;
const vertical = style.paddingVertical ?? style.padding;
const horizontal =
style.paddingInline ?? style.paddingHorizontal ?? style.padding;
const vertical = style.paddingBlock ?? style.paddingVertical ?? style.padding;
return {
...(horizontal != null
? { paddingLeft: horizontal, paddingRight: horizontal }
Expand Down
16 changes: 16 additions & 0 deletions packages/heuristic-table-plugin/src/shared-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ export interface TColumnConstraints extends TConstraintsBase {
export interface TCellConstraints extends TConstraintsBase {
/** Preferred fraction of the table width, resolved during distribution. */
percentWidth?: number;
/**
* The cell's own horizontal padding and border, already included in
* {@link TConstraintsBase.minWidth} and {@link TCellConstraints.maxWidth}.
*
* @remarks
* Reported separately so that distribution can tell a column's content
* apart from the spacing wrapped around it. Surplus width is shared over
* content alone: under the collapsing border model each cell owns a
* different set of the boundaries it touches, and weighting the share by the
* whole border box would turn that bookkeeping difference into a visible
* one, dealing a column that merely paints one more border edge more content
* width than its neighbours.
*
* @defaultValue 0, when a caller builds constraints by hand.
*/
horizontalSpace?: number;
/**
* The width at which this cell would stop benefiting from more space — the
* *maximum cell width* of {@link https://www.w3.org/TR/CSS21/tables.html#auto-table-layout | CSS 2.1 §17.5.2.2},
Expand Down