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): fill table height, preserve row heights,…
… and support border-spacing
  • Loading branch information
5ZYSZ3K committed Sep 15, 2026
commit a70870e8085d88150a5a65faf1c5eada391a7143
11 changes: 9 additions & 2 deletions packages/heuristic-table-plugin/src/HTMLTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,23 @@ function Container({
availableWidth: number;
}>) {
const scroll = shouldScrollTable(tableWidth, availableWidth);
// Carry the wrapper's spare height through to the rows, including when
// horizontal overflow requires a ScrollView. Keep the content's height floor.
return scroll
? React.createElement(
ScrollView,
{
contentContainerStyle: { width: tableWidth },
style: { width: availableWidth },
style: { width: availableWidth, flexGrow: 1, flexShrink: 0 },
horizontal: true
},
children
)
: React.createElement(View, { style: { width: tableWidth } }, children);
: React.createElement(
View,
{ style: { width: tableWidth, flexGrow: 1, flexShrink: 0 } },
children
);
}

/**
Expand Down Expand Up @@ -85,6 +91,7 @@ const HTMLTable = memo(function HTMLTable({
>
{React.createElement(TreeRenderer, {
node: layout.renderTree,
borderSpacing: layout.borderSpacing,
config,
cellStyles: layout.cellStyles,
borderCollapse: layout.borderCollapse,
Expand Down
41 changes: 35 additions & 6 deletions packages/heuristic-table-plugin/src/TableLayout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { sum } from 'ramda';
import resolveBorderSpacing, {
BorderSpacing
} from './helpers/resolveBorderSpacing';
import type { CellContentBox } from './CellContentWidthContext';
import { ViewStyle } from 'react-native';
import { TNode } from '@native-html/render';
Expand Down Expand Up @@ -27,6 +30,7 @@ const DEFAULT_FORCE_STRETCH = true;

export default class TableLayout {
public readonly display: Display;
public readonly borderSpacing: BorderSpacing;
public readonly columnWidths: number[];
public readonly totalWidth: number;
public readonly borderCollapse: boolean;
Expand Down Expand Up @@ -63,6 +67,7 @@ export default class TableLayout {
constructor(tnode: TNode, config: Settings, cellContentBox?: CellContentBox) {
const style = tnode.styles.nativeBlockRet;
this.borderCollapse = resolveBorderCollapse(tnode, config.borderCollapse);
this.borderSpacing = resolveBorderSpacing(tnode, this.borderCollapse);
const containingWidth = resolveAvailableWidth(
tnode,
config.contentWidth,
Expand Down Expand Up @@ -107,6 +112,9 @@ export default class TableLayout {
forceStretch
});
fillTableDisplay(tnode, display);
const spacingWidth = display.cells.length
? (display.maxX + 2) * this.borderSpacing.horizontal
: 0;
const declaredColumnWidths = extractColumnWidths(tnode);
const configStyles = new Map<TNode, ViewStyle | null>();
const measure = () => {
Expand All @@ -120,20 +128,33 @@ export default class TableLayout {
...style,
...resolved.tableBorderStyle
});
display.contentWidth = Math.max(0, usedTableWidth - insets);
display.contentWidth = Math.max(
0,
usedTableWidth - insets - spacingWidth
);
const computer = new TCellConstraintsComputer({
contentWidth: display.contentWidth,
baseFontCoeff: config.baseFontCoeff,
fontWeightCoeffs: config.fontWeightCoeffs
});
for (const cell of display.cells) {
cell.constraints = computer.computeCellConstraints(
const constraints = computer.computeCellConstraints(
cell.tnode,
resolved.cellStyles.get(cell.tnode)!.style
);
// A spanning cell also occupies the gaps between its columns.
const internalSpacing = (cell.lenX - 1) * this.borderSpacing.horizontal;
cell.constraints = {
...constraints,
minWidth: Math.max(0, constraints.minWidth - internalSpacing),
maxWidth: Math.max(0, constraints.maxWidth - internalSpacing)
};
}
let columnWidths = computeColumnWidths(display, declaredColumnWidths);
const minLayoutWidth = Math.max(0, (minWidth ?? 0) - insets);
const minLayoutWidth = Math.max(
0,
(minWidth ?? 0) - insets - spacingWidth
);
if (sum(columnWidths) < minLayoutWidth) {
const raised = computeColumnWidths(
{ ...display, contentWidth: minLayoutWidth, forceStretch: true },
Expand All @@ -147,7 +168,11 @@ export default class TableLayout {
if (config.getStyleForCell) {
// Freeze callback results against provisional widths. Re-evaluating after
// each resize could oscillate for a callback that branches on width.
for (const cell of makeTableCells(display, measured.columnWidths)) {
for (const cell of makeTableCells(
display,
measured.columnWidths,
this.borderSpacing.horizontal
)) {
const configured = config.getStyleForCell.call(null, cell);
configStyles.set(cell.tnode, configured ? { ...configured } : null);
}
Expand All @@ -161,8 +186,12 @@ export default class TableLayout {
this.assignableWidth = Math.max(0, this.usedWidth - measured.insets);
this.display = display;
this.columnWidths = measured.columnWidths;
this.totalWidth = sum(this.columnWidths);
this.cells = makeTableCells(display, this.columnWidths);
this.totalWidth = sum(this.columnWidths) + spacingWidth;
this.cells = makeTableCells(
display,
this.columnWidths,
this.borderSpacing.horizontal
);
this.renderTree = createRenderTree(this.cells);
}
}
45 changes: 42 additions & 3 deletions packages/heuristic-table-plugin/src/TreeRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { BorderSpacing } from './helpers/resolveBorderSpacing';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { TNode, TNodeRenderer } from '@native-html/render';
import { ResolvedCellStyle } from './helpers/resolveTableStyles';
Expand All @@ -13,6 +14,7 @@ const styles = StyleSheet.create({

export default function TreeRenderer({
node,
borderSpacing = { horizontal: 0, vertical: 0 },
config,
cellStyles,
borderCollapse,
Expand All @@ -23,6 +25,7 @@ export default function TreeRenderer({
renderLength
}: {
node: TableRenderNode;
borderSpacing?: BorderSpacing;
renderIndex: number;
renderLength: number;
config?: HeuristicTablePluginConfig;
Expand All @@ -48,7 +51,13 @@ export default function TreeRenderer({
);
if (node.type === 'cell') {
return (
<View style={{ width: node.width }}>
<View
style={{
width: node.width,
marginEnd: node.x + node.lenX <= maxX ? borderSpacing.horizontal : 0,
marginBottom: node.y + node.lenY <= maxY ? borderSpacing.vertical : 0
}}
>
<CellContentWidthContext.Provider value={cellContentBox}>
<TNodeRenderer
renderIndex={renderIndex}
Expand Down Expand Up @@ -77,6 +86,7 @@ export default function TreeRenderer({
node: v,
key: i,
config,
borderSpacing,
cellStyles,
borderCollapse,
tableBorderStyle,
Expand All @@ -86,16 +96,45 @@ export default function TreeRenderer({
renderLength: node.children.length
})
);
return <View style={styles.colContainer}>{children}</View>;
return (
<View
style={[
styles.colContainer,
node.type === 'root' &&
node.children.length > 0 && {
paddingHorizontal: borderSpacing.horizontal,
paddingVertical: borderSpacing.vertical
}
]}
>
{children}
</View>
);
}
if (node.type === 'row-container') {
// The render tree replaces source rows with flex containers. Preserve
// their height floor here; a row must still grow when its content is taller.
// A spanning cell does not impose its starting row's height on its whole
// synthetic row group.
const minHeight = node.children.reduce((height, child) => {
if (child.type !== 'cell' || child.lenY !== 1) return height;
const row = child.tnode.parent;
if (row?.tagName !== 'tr') return height;
const style = row.styles.nativeBlockRet;
return Math.max(
height,
typeof style.height === 'number' ? style.height : 0,
typeof style.minHeight === 'number' ? style.minHeight : 0
);
}, 0);
return (
<View style={styles.rowContainer}>
<View style={[styles.rowContainer, minHeight > 0 && { minHeight }]}>
{node.children.map((v, i) =>
React.createElement(TreeRenderer, {
node: v,
key: i,
config,
borderSpacing,
cellStyles,
borderCollapse,
tableBorderStyle,
Expand Down
21 changes: 19 additions & 2 deletions packages/heuristic-table-plugin/src/__tests__/HTMLTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,23 @@ describe('HTMLTable containers', () => {
);
});

it.each([100, 600])(
'lets the inner container fill the table height with %spx content',
(width) => {
const rendered = renderTable(
`<table style="height:260px"><tr><td style="width:${width}px">A</td></tr></table>`,
400
);
const wrapper = rendered.getByTestId('table-wrapper');
const scroll = rendered.UNSAFE_queryByType(ScrollView);
const container = scroll ?? wrapper.findAllByType(View)[0];
expect(StyleSheet.flatten(container.props.style)).toMatchObject({
flexGrow: 1,
flexShrink: 0
});
}
);

it('uses the capped table width for the wrapper and overflow viewport', () => {
const rendered = renderTable(
'<table style="max-width:300px"><tr><td style="width:300px">A</td><td style="width:300px">B</td></tr></table>',
Expand All @@ -73,7 +90,7 @@ describe('HTMLTable containers', () => {
expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ width: 300 });
const scroll = rendered.UNSAFE_getByType(ScrollView);
expect(scroll.props.horizontal).toBe(true);
expect(scroll.props.style).toEqual({ width: 300 });
expect(scroll.props.style).toMatchObject({ width: 300 });
expect(scroll.props.contentContainerStyle).toEqual({ width: 600 });
});

Expand All @@ -91,7 +108,7 @@ describe('HTMLTable containers', () => {
(html, width) => {
const rendered = renderTable(html, 400);
expect(rendered.getByTestId('table-wrapper')).toHaveStyle({ width });
expect(rendered.UNSAFE_getByType(ScrollView).props.style).toEqual({
expect(rendered.UNSAFE_getByType(ScrollView).props.style).toMatchObject({
width: 0
});
}
Expand Down
116 changes: 116 additions & 0 deletions packages/heuristic-table-plugin/src/__tests__/borderSpacing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { StyleSheet, View } from 'react-native';
import RenderHTML from '@native-html/render';
import renderers from '../index';
import TableLayout from '../TableLayout';
import resolveBorderSpacing from '../helpers/resolveBorderSpacing';
import { createTableTNode } from './utils';

describe('border spacing', () => {
it.each([
['border-spacing:8px 12px', 8, 12],
['border-spacing:5px', 5, 5],
['border-spacing:0', 0, 0],
['border-spacing:-1px', 0, 0],
['border-spacing:10%', 0, 0],
['border-spacing:1px 2px 3px', 0, 0],
['font-size:20px;border-spacing:0.5em 1em', 10, 20]
])('resolves %s', (style, horizontal, vertical) => {
const table = createTableTNode(
`<table style="${style}"><tr><td>A</td></tr></table>`
);
expect(resolveBorderSpacing(table, false)).toEqual({
horizontal,
vertical
});
expect(resolveBorderSpacing(table, true)).toEqual({
horizontal: 0,
vertical: 0
});
});

it('supports inherited spacing and the legacy cellspacing attribute', () => {
const inherited = createTableTNode(
'<div style="border-spacing:8px 12px"><table><tr><td>A</td></tr></table></div>'
);
expect(resolveBorderSpacing(inherited, false)).toEqual({
horizontal: 8,
vertical: 12
});
const legacy = createTableTNode(
'<table cellspacing="6"><tr><td>A</td></tr></table>'
);
expect(resolveBorderSpacing(legacy, false)).toEqual({
horizontal: 6,
vertical: 6
});
const override = createTableTNode(
'<table cellspacing="6" style="border-spacing:0"><tr><td>A</td></tr></table>'
);
expect(resolveBorderSpacing(override, false)).toEqual({
horizontal: 0,
vertical: 0
});
});

it('reserves gaps inside the table width and includes internal gaps in colspan widths', () => {
const table = createTableTNode(
'<table style="width:300px;border-spacing:8px 12px"><tr><td>A</td><td>B</td></tr><tr><td colspan="2">C</td></tr></table>'
);
const layout = new TableLayout(table, { contentWidth: 300 });
expect(layout.totalWidth).toBeCloseTo(300);
expect(layout.columnWidths.reduce((a, b) => a + b, 0)).toBeCloseTo(276);
expect(layout.cells[2].width).toBeCloseTo(284);
const collapsed = new TableLayout(table, {
contentWidth: 300,
borderCollapse: 'collapse'
});
expect(collapsed.cells[2].width).toBeCloseTo(300);
});

it('includes spacing when content overflows', () => {
const table = createTableTNode(
'<table style="border-spacing:8px"><tr><td style="width:200px;padding:0">A</td><td style="width:200px;padding:0">B</td></tr></table>'
);
const layout = new TableLayout(table, { contentWidth: 300 });
expect(layout.totalWidth).toBe(424);
expect(layout.assignableWidth).toBe(300);
});

it('paints gaps outside cells and only once around the grid', () => {
const rendered = render(
<RenderHTML
contentWidth={300}
renderers={renderers}
source={{
html: '<table style="border-spacing:8px 12px"><tr><td>A</td><td>B</td></tr><tr><td colspan="2">C</td></tr></table>'
}}
/>
);
const styles = rendered
.UNSAFE_getAllByType(View)
.map((view) => StyleSheet.flatten(view.props.style));
expect(
styles.filter(
(style) =>
style?.paddingHorizontal === 8 && style?.paddingVertical === 12
)
).toHaveLength(1);
expect(
styles.filter(
(style) => style?.marginEnd === 8 && style?.marginBottom === 12
)
).toHaveLength(1);
expect(
styles.filter(
(style) => style?.marginEnd === 0 && style?.marginBottom === 12
)
).toHaveLength(1);
expect(
styles.filter(
(style) => style?.marginEnd === 0 && style?.marginBottom === 0
)
).toHaveLength(1);
});
});
Loading