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
feat(heuristic-table-plugin): implement border collapsing and stick t…
…o default styles defined
  • Loading branch information
5ZYSZ3K committed Sep 9, 2026
commit 4237ad303796bd61d93e987c835338962fbba09b
48 changes: 43 additions & 5 deletions packages/heuristic-table-plugin/src/HTMLTable.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { memo, PropsWithChildren } from 'react';
import React, { memo, PropsWithChildren, useMemo } from 'react';
import { ScrollView, View } from 'react-native';
import TreeRenderer from './TreeRenderer';
import { HTMLTableProps } from './shared-types';
import { getHorizontalInsets } from './helpers/measure';
import relaxHeightConstraint from './helpers/relaxHeightConstraint';
import { getCollapsedTableBorderStyle } from './helpers/tableStyles';

export function shouldScrollTable(
tableWidth: number,
Expand Down Expand Up @@ -56,27 +56,65 @@ const HTMLTable = memo(function HTMLTable({
// it, which is what `contentWidth` would be if it were narrowed on the way
// down the tree. Sizing the container off `settings.contentWidth` instead
// would spill the table out of every padded ancestor it sits in.
const insets = getHorizontalInsets(props.tnode.styles.nativeBlockRet);
const insets = layout.horizontalInsets;
const getStyleForCell = config.getStyleForCell;
// `getStyleForCell` is handed cells the layout only produces at the end of
// its own work, so the outer collapsed edge is narrowed once more here, now
// that every border the edge cells actually paint is known. Leaving it to
// the layout alone would let a border only the config declares lose to the
// weaker one source CSS resolved, and be painted by neither. The insets the
// layout measured against still come from source CSS: a config border
// changes what the table paints, not how wide it was laid out.
const tableBorderStyle = useMemo(
() =>
layout.borderCollapse && getStyleForCell
? getCollapsedTableBorderStyle(
{
cells: layout.cells,
maxX: layout.display.maxX,
maxY: layout.display.maxY
},
layout.tableBorderStyle ?? {},
(cell) => ({
...cell.tnode.styles.nativeBlockRet,
...getStyleForCell(cell)
})
)
: layout.tableBorderStyle,
[getStyleForCell, layout]
);
return (
<TDefaultRenderer
{...props}
style={{
// An explicit height on a table is a minimum height in HTML, so that
// the table still grows to fit its rows.
...relaxHeightConstraint(props.style),
// In collapsed mode, one resolved wrapper edge represents the table
// and all cells which meet it. Apply it after source/native styles so
// a weaker table border cannot replace a stronger cell border.
...tableBorderStyle,
// `usedWidth` already accounts for both the room the ancestors leave
// and the table's own `width`/`max-width`, so the painted box stops at
// whichever of the two comes first and the overflow goes to the
// scroller inside. A table narrower than that keeps its own size,
// insets included.
width: Math.min(tableWidth + insets, layout.usedWidth)
}}>
}}
>
<Container
tableWidth={tableWidth}
availableWidth={layout.assignableWidth}>
availableWidth={layout.assignableWidth}
>
{React.createElement(TreeRenderer, {
node: layout.renderTree,
config,
borderCollapse: layout.borderCollapse,
// Cells need the edge the wrapper resolved, not just their position
// in the matrix: an outer boundary it leaves bare is still theirs.
tableBorderStyle,
maxX: layout.display.maxX,
maxY: layout.display.maxY,
renderIndex: props.renderIndex,
renderLength: props.renderLength
})}
Expand Down
79 changes: 58 additions & 21 deletions packages/heuristic-table-plugin/src/TableLayout.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { sum } from 'ramda';
import { ViewStyle } from 'react-native';
import { TNode } from '@native-html/render';
import computeColumnWidths from './helpers/computeColumnWidths';
import createRenderTree from './helpers/createRenderTree';
import createRenderTree, { makeTableCells } from './helpers/createRenderTree';
import fillTableDisplay, {
createEmptyDisplay
createEmptyDisplay,
measureDisplay
} from './helpers/fillTableDisplay';
import TCellConstraintsComputer from './helpers/TCellConstraintsComputer';
import { Display, Settings, TableRoot } from './shared-types';
import { Display, Settings, TableCell, TableRoot } from './shared-types';
import extractColumnWidths from './helpers/extractColumnWidths';
import { clampWidth, resolveWidthConstraints } from './helpers/resolveWidth';
import resolveAvailableWidth from './helpers/resolveAvailableWidth';
import { getHorizontalInsets, getHorizontalMargins } from './helpers/measure';
import {
getCollapsedTableBorderStyle,
resolveBorderCollapse
} from './helpers/tableStyles';

/**
* Tables fill the width their containing block leaves them unless the config
Expand All @@ -23,6 +29,9 @@ export default class TableLayout {
public readonly display: Display;
public readonly columnWidths: number[];
public readonly totalWidth: number;
public readonly borderCollapse: boolean;
public readonly tableBorderStyle: ViewStyle | null;
public readonly horizontalInsets: number;
/**
* The border-box width the table may occupy, after the horizontal spacing of
* every ancestor and the table's own margins have been subtracted from
Expand All @@ -47,11 +56,20 @@ export default class TableLayout {
* its ancestors and its own `max-width` allow.
*/
public readonly usedWidth: number;
/**
* Every cell of the table, at the width the columns resolved to.
*
* @remarks
* This is what {@link HeuristicTablePluginConfig.getStyleForCell} is called
* with, so it is the earliest point at which the styles that function
* contributes can take part in the collapsing border model.
*/
public readonly cells: TableCell[];
public readonly renderTree: TableRoot;
constructor(tnode: TNode, config: Settings) {
const style = tnode.styles.nativeBlockRet;
this.borderCollapse = resolveBorderCollapse(tnode, config.borderCollapse);
const containingWidth = resolveAvailableWidth(tnode, config.contentWidth);
const insets = getHorizontalInsets(style);
const availableWidth = Math.max(
0,
containingWidth - getHorizontalMargins(style)
Expand All @@ -73,6 +91,30 @@ export default class TableLayout {
minWidth,
maxWidth
);
const forceStretch =
(config.forceStretch ?? DEFAULT_FORCE_STRETCH) ||
declaredTableWidth !== null;
// Cell coordinates and spans do not depend on the width the table resolves
// to; only their constraints do, and measuring text is the costly half of
// a layout pass. Laying the grid out first lets the collapsing model
// resolve the table's own borders — which feed the insets the cells are
// then measured against — without a second pass over the matrix.
const display = createEmptyDisplay({
...config,
// A table with a specified width distributes that width over its
// columns; shrink-to-fit only applies when the table width is auto,
// and is opt-in.
forceStretch
});
fillTableDisplay(tnode, display);
this.tableBorderStyle = this.borderCollapse
? getCollapsedTableBorderStyle(display, style)
: null;
const effectiveTableStyle = this.tableBorderStyle
? { ...style, ...this.tableBorderStyle }
: style;
const insets = getHorizontalInsets(effectiveTableStyle);
this.horizontalInsets = insets;
this.availableWidth = availableWidth;
// A table capped by `max-width` — or one whose declared width is narrower
// than its content demands — offers its columns less room than its
Expand All @@ -81,22 +123,16 @@ export default class TableLayout {
this.usedWidth = Math.max(0, Math.min(usedTableWidth, availableWidth));
this.assignableWidth = Math.max(0, this.usedWidth - insets);
const layoutContentWidth = Math.max(0, usedTableWidth - insets);
const layoutSettings = {
...config,
contentWidth: layoutContentWidth,
// A table with a specified width distributes that width over its columns;
// shrink-to-fit only applies when the table width is auto, and is opt-in.
forceStretch:
(config.forceStretch ?? DEFAULT_FORCE_STRETCH) ||
declaredTableWidth !== null
};
const computer = new TCellConstraintsComputer({
contentWidth: layoutContentWidth,
baseFontCoeff: config.baseFontCoeff,
fontWeightCoeffs: config.fontWeightCoeffs
});
this.display = createEmptyDisplay(layoutSettings);
fillTableDisplay(tnode, this.display, computer);
display.contentWidth = layoutContentWidth;
measureDisplay(
display,
new TCellConstraintsComputer({
contentWidth: layoutContentWidth,
baseFontCoeff: config.baseFontCoeff,
fontWeightCoeffs: config.fontWeightCoeffs
})
);
this.display = display;
// Declared column widths are independent of the width they will be
// resolved against, so the same set serves the min-width pass below.
const declaredColumnWidths = extractColumnWidths(tnode);
Expand All @@ -120,6 +156,7 @@ export default class TableLayout {
}
this.columnWidths = columnWidths;
this.totalWidth = sum(columnWidths);
this.renderTree = createRenderTree(this.display, this.columnWidths);
this.cells = makeTableCells(this.display, this.columnWidths);
this.renderTree = createRenderTree(this.cells);
}
}
28 changes: 26 additions & 2 deletions packages/heuristic-table-plugin/src/TreeRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { TNodeRenderer } from '@native-html/render';
import { HeuristicTablePluginConfig, TableRenderNode } from './shared-types';

Expand All @@ -11,13 +11,21 @@ const styles = StyleSheet.create({
export default function TreeRenderer({
node,
config,
borderCollapse,
tableBorderStyle,
maxX,
maxY,
renderIndex,
renderLength
}: {
node: TableRenderNode;
renderIndex: number;
renderLength: number;
config?: HeuristicTablePluginConfig;
borderCollapse: boolean;
tableBorderStyle: ViewStyle | null;
maxX: number;
maxY: number;
}) {
if (node.type === 'cell') {
return (
Expand All @@ -26,7 +34,15 @@ export default function TreeRenderer({
renderIndex={renderIndex}
renderLength={renderLength}
propsFromParent={
{ cell: node, collapsedMarginTop: null, config } as any
{
cell: node,
collapsedMarginTop: null,
config,
borderCollapse,
tableBorderStyle,
maxX,
maxY
} as any
}
tnode={node.tnode}
/>
Expand All @@ -39,6 +55,10 @@ export default function TreeRenderer({
node: v,
key: i,
config,
borderCollapse,
tableBorderStyle,
maxX,
maxY,
renderIndex: i,
renderLength: node.children.length
})
Expand All @@ -53,6 +73,10 @@ export default function TreeRenderer({
node: v,
key: i,
config,
borderCollapse,
tableBorderStyle,
maxX,
maxY,
renderIndex: i,
renderLength: node.children.length
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,16 @@ interface TCellStats {
*/
horizontalSpace: number;
/**
* The maximum of explicit widths or min-widths of block elements in this
* cell, including margins.
* The maximum of explicit widths or min-widths of the block elements *inside*
* this cell, including margins. Content-box against the cell, so the cell's
* own horizontal spacing still has to be added on top.
*/
blockWidth: number;
/**
* The border-box width the cell itself declares, or `null` when it declares
* none. Already holds the cell's padding and border.
*/
cellBoxWidth: number | null;
/**
* Text stats in this cell.
*/
Expand All @@ -43,6 +49,7 @@ interface TCellStats {
function getInitCellStatsForTnode(tnode: TNode): TCellStats {
return {
blockWidth: 0,
cellBoxWidth: null,
horizontalSpace: getHorizontalSpacing(tnode.styles.nativeBlockRet),
textStats: []
};
Expand Down Expand Up @@ -206,8 +213,19 @@ export default class TCellConstraintsComputer {
if (tnode.type === 'block') {
const width = this.resolveBlockWidth(tnode, isCellRoot);
if (width !== null) {
const margins = getHorizontalMargins(tnode.styles.nativeBlockRet);
stats.blockWidth = Math.max(stats.blockWidth, width + margins);
if (isCellRoot) {
// React Native lays out with `box-sizing: border-box`, and CSS
// gives a table cell that same box model, so the width a cell
// declares already holds its padding and border. It is kept apart
// from the descendant widths below, which are content-box against
// the cell and so do have to grow by its spacing. Margins play no
// part either: a table cell has none, and the cell renderer zeroes
// whatever a stylesheet asked for.
stats.cellBoxWidth = width;
} else {
const margins = getHorizontalMargins(tnode.styles.nativeBlockRet);
stats.blockWidth = Math.max(stats.blockWidth, width + margins);
}
}
}
tnode.children.forEach((n) => this.assembleCellStats(n, stats, false));
Expand Down Expand Up @@ -259,12 +277,18 @@ export default class TCellConstraintsComputer {
// greater than MCW, W is the minimum cell width", and the maximum cell
// width is likewise raised by the column 'width'. So an explicit width
// lifts *both* bounds — never just one, or the cell would end up
// narrower than the width it asked for.
const minWidth =
Math.max(blockWidth, textConstrains.minWidth) + stats.horizontalSpace;
const maxWidth =
// narrower than the width it asked for. Being a border-box width, it
// bounds the spaced total rather than joining the content it holds.
const cellBoxWidth = stats.cellBoxWidth ?? 0;
const minWidth = Math.max(
Math.max(blockWidth, textConstrains.minWidth) + stats.horizontalSpace,
cellBoxWidth
);
const maxWidth = Math.max(
Math.max(blockWidth, textConstrains.contentDensity) +
stats.horizontalSpace;
stats.horizontalSpace,
cellBoxWidth
);
return {
minWidth,
// `max-width` caps the width the cell would *like*, but never takes it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,26 @@ describe('TCellConstraintsComputer', () => {
expect(minWidth).toBeLessThan(220);
});

it('should treat a declared cell width as a border-box one', () => {
// React Native lays out with `box-sizing: border-box`, and so does CSS
// for a table cell: the padding sits inside the 200px, it does not
// widen the column to 216px.
const { minWidth, maxWidth } = constraintsFor(
'<td style="width:200px;padding:8px">a</td>'
);
expect(minWidth).toBe(200);
expect(maxWidth).toBe(200);
});

it('should still add cell spacing to a descendant width', () => {
// A block inside the cell is content-box against it, so the cell has to
// grow by its own padding to hold the 200px the block asked for.
const { minWidth } = constraintsFor(
'<td style="padding:8px"><div style="width:200px"></div></td>'
);
expect(minWidth).toBe(216);
});

it('should read the presentational width attribute', () => {
const { minWidth } = constraintsFor('<td width="200">a</td>');
expect(minWidth).toBeGreaterThanOrEqual(200);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fillTableDisplay, { createEmptyDisplay } from '../fillTableDisplay';
import { createTableTNode } from './utils';
import createRenderTree from '../createRenderTree';
import createRenderTree, { makeTableCells } from '../createRenderTree';
import TCellConstraintsComputer from '../TCellConstraintsComputer';
import {
TableCell,
Expand All @@ -14,7 +14,7 @@ function makeRenderTree(html: string, columnWidths: number[]) {
const display = createEmptyDisplay({ contentWidth: 1000 });
const computer = new TCellConstraintsComputer({});
fillTableDisplay(tnode, display, computer);
return createRenderTree(display, columnWidths);
return createRenderTree(makeTableCells(display, columnWidths));
}

function rowContainer(
Expand Down
Loading