Enh: container/section vertical alignment + box-model/anim/vis #19
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user