fix(builder): ColumnLayout.toHtml derives column widths from split (D2b)

toHtml emitted only the wrapper's flex/gap style and passed childrenHtml
through untouched, so a non-default split percentage never showed up in
the export -- each column's width silently reverted to whatever was baked
into its own inline style at node-creation time. toHtml has no direct
handle on individual children to rewrite their styles (it only receives
the already-concatenated childrenHtml string), so emit a scoped <style>
block with nth-child rules (!important, to win over any stale per-child
inline flex) computed via the same getWidths(split, columns) mapping the
editor render uses. Precedent: Menu/Navbar toHtml already emit scoped
<style> blocks for hover CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:46:44 -07:00
parent 2887cc9bdf
commit cdcc3969bc
2 changed files with 49 additions and 1 deletions
@@ -0,0 +1,31 @@
import { describe, test, expect } from 'vitest';
import { ColumnLayout } from './ColumnLayout';
const toHtml = (ColumnLayout as any).toHtml;
describe('ColumnLayout.toHtml width export from split', () => {
test('non-default split (70-30) exports per-column width CSS matching each column', () => {
const { html } = toHtml({ columns: 2, split: '70-30', gap: '16px' }, '<div>A</div><div>B</div>');
// First column gets 70%, second gets 30% (same mapping as getWidths()).
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(70% - 16px\)/);
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(30% - 16px\)/);
});
test('default 50-50 split still exports equal widths', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px' }, '<div>A</div><div>B</div>');
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(50% - 16px\)/);
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(50% - 16px\)/);
});
test('3-column 33-33-33 split exports three width rules', () => {
const { html } = toHtml({ columns: 3, split: '33-33-33', gap: '16px' }, '<div>A</div><div>B</div><div>C</div>');
expect(html).toMatch(/nth-child\(1\)[^}]*calc\(33\.333% - 16px\)/);
expect(html).toMatch(/nth-child\(2\)[^}]*calc\(33\.333% - 16px\)/);
expect(html).toMatch(/nth-child\(3\)[^}]*calc\(33\.333% - 16px\)/);
});
test('childrenHtml is preserved in the output', () => {
const { html } = toHtml({ columns: 2, split: '70-30', gap: '16px' }, '<div>A</div><div>B</div>');
expect(html).toContain('<div>A</div><div>B</div>');
});
});
+18 -1
View File
@@ -115,7 +115,11 @@ ColumnLayout.craft = {
/* ---------- HTML export ---------- */
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
const columns = props.columns || 2;
const split = props.split || '50-50';
const gap = props.gap || '16px';
const widths = getWidths(split, columns);
const outerStyle = cssPropsToString({
display: 'flex',
flexWrap: 'wrap',
@@ -124,7 +128,20 @@ ColumnLayout.craft = {
...props.style,
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
// Each column is exported as an independently-serialized child node, so
// toHtml has no direct handle on individual children to rewrite their
// inline flex-basis. Instead, scope an nth-child CSS rule (with
// !important, to win over any stale inline flex baked into a child at
// creation time) to a generated class -- same width mapping (getWidths)
// the editor render uses. Precedent: Menu/Navbar toHtml already emit
// scoped <style> blocks for hover CSS.
const scopeId = `cols-${Math.random().toString(36).slice(2, 8)}`;
const widthCss = widths
.map((w, i) => `.${scopeId} > :nth-child(${i + 1}) { flex: 0 0 calc(${w} - ${gap}) !important; }`)
.join('\n ');
return {
html: `<div${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
html: `<style>\n ${widthCss}\n</style>\n<div class="${scopeId}"${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
};
};