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)', () => {
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');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).not.toContain('display:flex');
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');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
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', () => {
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('min-height:500px');
});
+17 -15
View File
@@ -43,17 +43,19 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
return {};
};
// Container is ALWAYS display:flex/flex-direction:column at its root (both
// in the editor render below and in toHtml) so that `style.justifyContent`
// (the new Vertical Alignment control, paired with `style.minHeight`) has an
// axis to act on. This is a no-op visually for existing content: a column
// flex container with default `justify-content:flex-start` and
// `align-items:stretch` reproduces ordinary block-flow stacking (children
// stack top-to-bottom, full width) byte-for-byte, so nothing published
// before this feature shipped changes appearance. `flexAlignFromTextAlign`
// above still supplies `alignItems` (cross-axis, i.e. horizontal position)
// from `textAlign` independently -- the two controls act on orthogonal axes
// and don't conflict.
// Container only becomes display:flex/flex-direction:column at its root
// (both in the editor render below and in toHtml) when the user has
// actually set `style.justifyContent` (the Vertical Alignment control,
// paired with `style.minHeight`) -- i.e. the flex conversion is gated on
// vertical-align actually being in use, not unconditional. In-flow children
// of a flex container get CSS-blockified, which would force components that
// deliberately render `display:inline-block` (ButtonLink, Icon) to stack
// vertically instead of sitting side-by-side -- a real visual regression for
// any container/section that never touches vertical alignment, not a no-op.
// So plain block flow (no `display`/`flex-direction` at all) is preserved
// 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> = ({
style = {},
@@ -70,12 +72,12 @@ export const Container: UserComponent<ContainerProps> = ({
const safeTag = sanitizeContainerTag(tag);
const needsBoxedWrapper = contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(style.textAlign);
const hasVerticalAlign = !!style.justifyContent;
const outerStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
minHeight: '40px',
...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
...(needsBoxedWrapper ? {} : flexStyles),
};
@@ -142,11 +144,11 @@ Container.craft = {
const tag = sanitizeContainerTag(props.tag);
const isBoxed = props.contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
const hasVerticalAlign = !!props.style?.justifyContent;
const outerCss: CSSProperties = {
display: 'flex',
flexDirection: 'column',
...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
...(isBoxed ? {} : flexStyles),
};
@@ -75,14 +75,27 @@ describe('Section.toHtml shape divider color/height XSS hardening', () => {
});
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');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
expect(html).not.toContain('display:flex');
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');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
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 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 (
<section
@@ -110,16 +117,9 @@ export const Section: UserComponent<SectionProps> = ({
id={anchorId || undefined}
style={{
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,
...style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
}}
>
{hasTopDivider && (
@@ -227,13 +227,13 @@ function buildDividerHtml(
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
const hasVerticalAlign = !!props.style?.justifyContent;
const outerStyle = cssPropsToString({
width: '100%',
display: 'flex',
flexDirection: 'column',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...props.style,
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
});
const innerStyle = cssPropsToString({
maxWidth: props.innerMaxWidth || '1200px',