diff --git a/craft/src/components/layout/Container.tsx b/craft/src/components/layout/Container.tsx index a57a98b..44ec28d 100644 --- a/craft/src/components/layout/Container.tsx +++ b/craft/src/components/layout/Container.tsx @@ -57,6 +57,26 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope // supplies its own conditional flex conversion (cross-axis alignItems from // `textAlign`) independently -- unrelated to this gate. +/** + * The layout declarations Container DERIVES from its style (not the style + * itself): `outer` goes on the root element, `inner` on the boxed wrapper div + * (empty when not boxed -- the alignment flex then sits on the root). One + * source for the editor render, `toHtml` and the per-device `@media` rules + * (`responsiveLayout` below), so a tablet/mobile Alignment or Vertical + * alignment override publishes exactly as the editor shows it (finding 7). + */ +export const containerLayout = ( + style: CSSProperties | undefined, + isBoxed: boolean, +): { outer: CSSProperties; inner: CSSProperties } => { + const flexStyles = flexAlignFromTextAlign(style?.textAlign); + const outer: CSSProperties = { + ...(style?.justifyContent ? { display: 'flex', flexDirection: 'column' } : {}), + ...(isBoxed ? {} : flexStyles), + }; + return { outer, inner: isBoxed ? flexStyles : {} }; +}; + export const Container: UserComponent = ({ style = {}, tag = 'div', @@ -71,15 +91,15 @@ export const Container: UserComponent = ({ const safeTag = sanitizeContainerTag(tag); const needsBoxedWrapper = contentWidth === 'boxed'; - const flexStyles = flexAlignFromTextAlign(style.textAlign); - const hasVerticalAlign = !!style.justifyContent; + // `style` is the device-resolved style (RenderNode), so the derived flex + // follows the current device's Alignment / Vertical alignment. + const layout = containerLayout(style, needsBoxedWrapper); const outerStyle: CSSProperties = { minHeight: '40px', ...style, - ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), - ...(needsBoxedWrapper ? {} : flexStyles), + ...layout.outer, }; // cssId is the user-facing "CSS ID" advanced field; it takes precedence @@ -98,7 +118,7 @@ export const Container: UserComponent = ({ className: cssClass || undefined, }, needsBoxedWrapper - ? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children) + ? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...layout.inner } }, children) : children, ); @@ -144,13 +164,11 @@ Container.craft = { (Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => { const tag = sanitizeContainerTag(props.tag); const isBoxed = props.contentWidth === 'boxed'; - const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); - const hasVerticalAlign = !!props.style?.justifyContent; + const layout = containerLayout(props.style, isBoxed); const outerCss: CSSProperties = { ...props.style, - ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), - ...(isBoxed ? {} : flexStyles), + ...layout.outer, }; if (props.fullWidth) { @@ -165,9 +183,21 @@ Container.craft = { const classAttr = props.cssClass ? ` class="${escapeAttr(props.cssClass)}"` : ''; if (isBoxed) { - const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles }); + const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...layout.inner }); return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; } return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; }; + +/** + * Responsive-export hook (see `DerivedLayoutFn` in utils/responsive-style.ts): + * the derived layout for a device-resolved style, keyed by selector suffix + * relative to the node's `[data-wsb-r]` scope. In boxed mode the alignment + * flex lives on the only child, the wrapper div. + */ +(Container as any).responsiveLayout = (style: CSSProperties, props: ContainerProps): Record => { + const isBoxed = props.contentWidth === 'boxed'; + const layout = containerLayout(style, isBoxed); + return isBoxed ? { '': layout.outer, '>div': layout.inner } : { '': layout.outer }; +}; diff --git a/craft/src/components/layout/Section.tsx b/craft/src/components/layout/Section.tsx index d57366e..3f796a2 100644 --- a/craft/src/components/layout/Section.tsx +++ b/craft/src/components/layout/Section.tsx @@ -88,6 +88,15 @@ const ShapeDivider: React.FC<{ /* ---------- Component ---------- */ +/** + * The layout Section DERIVES from its style: a column flex root when + * Vertical alignment (`justifyContent`) is set. Shared by the editor render + * (which receives the device-resolved style), `toHtml`, and the per-device + * `@media` rules (`responsiveLayout` below) -- finding 7. + */ +export const sectionLayout = (style: CSSProperties | undefined): CSSProperties => + style?.justifyContent ? { display: 'flex', flexDirection: 'column' } : {}; + export const Section: UserComponent = ({ style = {}, innerMaxWidth = '1200px', @@ -110,8 +119,6 @@ export const Section: UserComponent = ({ // 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 (
{ if (ref) connect(drag(ref)); }} @@ -120,7 +127,7 @@ export const Section: UserComponent = ({ width: '100%', position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, ...style, - ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), + ...sectionLayout(style), }} > {hasTopDivider && ( @@ -228,13 +235,11 @@ 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%', position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined, ...props.style, - ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), + ...sectionLayout(props.style), }); const innerStyle = cssPropsToString({ maxWidth: props.innerMaxWidth || '1200px', @@ -251,3 +256,6 @@ function buildDividerHtml( html: `${topHtml}${childrenHtml}${bottomHtml}
`, }; }; + +/** Responsive-export hook (see `DerivedLayoutFn` in utils/responsive-style.ts). */ +(Section as any).responsiveLayout = (style: CSSProperties): Record => ({ '': sectionLayout(style) }); diff --git a/craft/src/components/layout/responsive-flex.test.tsx b/craft/src/components/layout/responsive-flex.test.tsx new file mode 100644 index 0000000..3ea629b --- /dev/null +++ b/craft/src/components/layout/responsive-flex.test.tsx @@ -0,0 +1,213 @@ +import { describe, test, expect, afterEach } from 'vitest'; +import React from 'react'; +import { exportBodyHtml } from '../../utils/html-export'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; +import { DeviceProvider } from '../../state/DeviceContext'; +import { RenderNode } from '../../editor/RenderNode'; +import type { DeviceMode } from '../../types'; + +/** + * Finding 7: Container/Section derive their flex layout (textAlign → + * display:flex;flex-direction:column;align-items, and the vertical-align + * justifyContent → display:flex;flex-direction:column conversion) from the + * style. The editor renders from the device-RESOLVED style, so the published + * @media rules must carry the same derived declarations (computed from the + * resolved per-device style) and target the element that carries the flex + * (the inner div in boxed mode). + */ + +const node = (type: string, props: Record, nodes: string[] = [], extra: Record = {}) => ({ + type: { resolvedName: type }, + isCanvas: type === 'Container', + props, + displayName: type, + custom: {}, + hidden: false, + nodes, + linkedNodes: {}, + ...extra, +}); + +const containerState = (props: Record) => + JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['c1']), + c1: node('Container', { tag: 'div', cssId: 'c1', ...props }, ['t1'], { parent: 'ROOT' }), + t1: node('Heading', { text: 'Hi', level: 'h2', style: {} }, [], { parent: 'c1' }), + }); + +const sectionState = (props: Record) => + JSON.stringify({ + ROOT: node('Container', { tag: 'div', style: {} }, ['s1']), + s1: node('Section', { anchorId: 's1', innerMaxWidth: '1200px', ...props }, [], { + parent: 'ROOT', + isCanvas: false, + linkedNodes: { 'section-inner': 'sin' }, + }), + sin: node('Container', { tag: 'div', style: { maxWidth: '1200px', margin: '0 auto' } }, ['t1'], { parent: 's1' }), + t1: node('Heading', { text: 'Hi', level: 'h2', style: {} }, [], { parent: 'sin' }), + }); + +/** The body of the `@media { {...} }` rule for `selector`, or null. */ +function ruleFor(html: string, device: 'tablet' | 'mobile', selector: string): string | null { + const m = /