Enh: container/section vertical alignment + box-model/anim/vis #19

Merged
jknapp merged 2 commits from enh-containers into main 2026-07-14 13:56:11 +00:00
4 changed files with 65 additions and 33 deletions
Showing only changes of commit 4a426e3513 - Show all commits
@@ -65,14 +65,29 @@ describe('Container.toHtml tag allowlist (adversarial re-review, same class as C
}); });
describe('Container.toHtml vertical alignment (justify-content + min-height)', () => { describe('Container.toHtml vertical alignment (justify-content + min-height)', () => {
test('is always a column flex container (display:flex;flex-direction:column)', () => { // Regression lock: Container/Section must NOT unconditionally become a
// flex container. Flex-blockifies in-flow children, forcing components
// that deliberately render display:inline-block (ButtonLink, Icon) to
// stack vertically instead of sitting side-by-side -- a real visual
// regression for existing published pages that never touch vertical
// alignment.
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
const { html } = toHtml({}, 'child'); const { html } = toHtml({}, 'child');
expect(html).toContain('display:flex'); expect(html).not.toContain('display:flex');
expect(html).toContain('flex-direction:column'); expect(html).not.toContain('flex-direction');
}); });
test('style.justifyContent flows into the emitted style attribute', () => { test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
expect(html).toContain('min-height:400px');
});
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child'); const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:center'); expect(html).toContain('justify-content:center');
}); });
@@ -83,6 +98,8 @@ describe('Container.toHtml vertical alignment (justify-content + min-height)', (
test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => { test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => {
const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child'); const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:flex-end'); expect(html).toContain('justify-content:flex-end');
expect(html).toContain('min-height:500px'); expect(html).toContain('min-height:500px');
}); });
+17 -15
View File
@@ -43,17 +43,19 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
return {}; return {};
}; };
// Container is ALWAYS display:flex/flex-direction:column at its root (both // Container only becomes display:flex/flex-direction:column at its root
// in the editor render below and in toHtml) so that `style.justifyContent` // (both in the editor render below and in toHtml) when the user has
// (the new Vertical Alignment control, paired with `style.minHeight`) has an // actually set `style.justifyContent` (the Vertical Alignment control,
// axis to act on. This is a no-op visually for existing content: a column // paired with `style.minHeight`) -- i.e. the flex conversion is gated on
// flex container with default `justify-content:flex-start` and // vertical-align actually being in use, not unconditional. In-flow children
// `align-items:stretch` reproduces ordinary block-flow stacking (children // of a flex container get CSS-blockified, which would force components that
// stack top-to-bottom, full width) byte-for-byte, so nothing published // deliberately render `display:inline-block` (ButtonLink, Icon) to stack
// before this feature shipped changes appearance. `flexAlignFromTextAlign` // vertically instead of sitting side-by-side -- a real visual regression for
// above still supplies `alignItems` (cross-axis, i.e. horizontal position) // any container/section that never touches vertical alignment, not a no-op.
// from `textAlign` independently -- the two controls act on orthogonal axes // So plain block flow (no `display`/`flex-direction` at all) is preserved
// and don't conflict. // unless vertical-align is set. `flexAlignFromTextAlign` above still
// supplies its own conditional flex conversion (cross-axis alignItems from
// `textAlign`) independently -- unrelated to this gate.
export const Container: UserComponent<ContainerProps> = ({ export const Container: UserComponent<ContainerProps> = ({
style = {}, style = {},
@@ -70,12 +72,12 @@ export const Container: UserComponent<ContainerProps> = ({
const safeTag = sanitizeContainerTag(tag); const safeTag = sanitizeContainerTag(tag);
const needsBoxedWrapper = contentWidth === 'boxed'; const needsBoxedWrapper = contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(style.textAlign); const flexStyles = flexAlignFromTextAlign(style.textAlign);
const hasVerticalAlign = !!style.justifyContent;
const outerStyle: CSSProperties = { const outerStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
minHeight: '40px', minHeight: '40px',
...style, ...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
...(needsBoxedWrapper ? {} : flexStyles), ...(needsBoxedWrapper ? {} : flexStyles),
}; };
@@ -142,11 +144,11 @@ Container.craft = {
const tag = sanitizeContainerTag(props.tag); const tag = sanitizeContainerTag(props.tag);
const isBoxed = props.contentWidth === 'boxed'; const isBoxed = props.contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
const hasVerticalAlign = !!props.style?.justifyContent;
const outerCss: CSSProperties = { const outerCss: CSSProperties = {
display: 'flex',
flexDirection: 'column',
...props.style, ...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(isBoxed ? {} : flexStyles), ...(isBoxed ? {} : flexStyles),
}; };
@@ -75,14 +75,27 @@ describe('Section.toHtml shape divider color/height XSS hardening', () => {
}); });
describe('Section.toHtml vertical alignment (justify-content + min-height)', () => { describe('Section.toHtml vertical alignment (justify-content + min-height)', () => {
test('is always a column flex container (display:flex;flex-direction:column)', () => { // Regression lock: same rationale as Container -- see Container.toHtml.test.ts.
// Section must not unconditionally become a flex container, or it
// blockifies inline-block children (ButtonLink, Icon) that are meant to
// sit side-by-side in existing published sections.
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
const { html } = toHtml({}, 'child'); const { html } = toHtml({}, 'child');
expect(html).toContain('display:flex'); expect(html).not.toContain('display:flex');
expect(html).toContain('flex-direction:column'); expect(html).not.toContain('flex-direction');
}); });
test('style.justifyContent flows into the emitted style attribute', () => { test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
expect(html).not.toContain('display:flex');
expect(html).not.toContain('flex-direction');
expect(html).toContain('min-height:600px');
});
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child'); const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).toContain('justify-content:center'); expect(html).toContain('justify-content:center');
}); });
+10 -10
View File
@@ -103,6 +103,13 @@ export const Section: UserComponent<SectionProps> = ({
const hasTopDivider = topDivider && topDivider !== 'none'; const hasTopDivider = topDivider && topDivider !== 'none';
const hasBottomDivider = bottomDivider && bottomDivider !== 'none'; const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
// Section's root only becomes a column flex container when the user has
// actually set `style.justifyContent` (Vertical Alignment control, paired
// with `style.minHeight`) -- see the matching note in Container.tsx for
// why an unconditional conversion is a real regression (blockifies
// deliberately inline-block children like ButtonLink/Icon) rather than a
// no-op, so plain block flow is preserved unless vertical-align is set.
const hasVerticalAlign = !!style.justifyContent;
return ( return (
<section <section
@@ -110,16 +117,9 @@ export const Section: UserComponent<SectionProps> = ({
id={anchorId || undefined} id={anchorId || undefined}
style={{ style={{
width: '100%', width: '100%',
// Section's root is always a column flex container so
// `style.justifyContent` (Vertical Alignment control, paired with
// `style.minHeight`) has an axis to act on -- see the matching note
// in Container.tsx for why this is a no-op for existing content
// (default justify-content/align-items reproduce ordinary block
// stacking).
display: 'flex',
flexDirection: 'column',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...style, ...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
}} }}
> >
{hasTopDivider && ( {hasTopDivider && (
@@ -227,13 +227,13 @@ function buildDividerHtml(
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => { (Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
const hasTopDivider = props.topDivider && props.topDivider !== 'none'; const hasTopDivider = props.topDivider && props.topDivider !== 'none';
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none'; const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
const hasVerticalAlign = !!props.style?.justifyContent;
const outerStyle = cssPropsToString({ const outerStyle = cssPropsToString({
width: '100%', width: '100%',
display: 'flex',
flexDirection: 'column',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...props.style, ...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
}); });
const innerStyle = cssPropsToString({ const innerStyle = cssPropsToString({
maxWidth: props.innerMaxWidth || '1200px', maxWidth: props.innerMaxWidth || '1200px',