fix(responsive): derive Container/Section flex layout from the resolved per-device style in export
Tablet/mobile Alignment and Vertical-alignment overrides published only the raw text-align/justify-content declarations, while the editor (which renders from the device-resolved style) also applied the derived flex layout (display:flex;flex-direction:column;align-items). Desktop-centred containers stayed centred on mobile, mobile-only centring/vertical-align did nothing, and in boxed mode the flex sits on the inner div which had no scope at all. - Container/Section: single containerLayout()/sectionLayout() helper used by render, toHtml and a new static responsiveLayout hook. - responsiveCssForNode takes an optional DerivedLayoutFn: per device it diffs the layout derived from the resolved style against what already applies (desktop for tablet, tablet for mobile), emits changes (and resets for dropped keys), and scopes boxed-mode inner rules as [data-wsb-r=x]>div. - html-export passes the component's responsiveLayout hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6afb1c9b22
commit
c00985a3a1
5 files changed
+348
-22
No files matched your search
@@ -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<ContainerProps> = ({
|
||||
style = {},
|
||||
tag = 'div',
|
||||
@@ -71,15 +91,15 @@ export const Container: UserComponent<ContainerProps> = ({
|
||||
|
||||
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<ContainerProps> = ({
|
||||
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}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
}
|
||||
|
||||
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<string, CSSProperties> => {
|
||||
const isBoxed = props.contentWidth === 'boxed';
|
||||
const layout = containerLayout(style, isBoxed);
|
||||
return isBoxed ? { '': layout.outer, '>div': layout.inner } : { '': layout.outer };
|
||||
};
|
||||
@@ -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<SectionProps> = ({
|
||||
style = {},
|
||||
innerMaxWidth = '1200px',
|
||||
@@ -110,8 +119,6 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
// 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
|
||||
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
@@ -120,7 +127,7 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
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: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
};
|
||||
};
|
||||
|
||||
/** Responsive-export hook (see `DerivedLayoutFn` in utils/responsive-style.ts). */
|
||||
(Section as any).responsiveLayout = (style: CSSProperties): Record<string, CSSProperties> => ({ '': sectionLayout(style) });
|
||||
@@ -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<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
||||
type: { resolvedName: type },
|
||||
isCanvas: type === 'Container',
|
||||
props,
|
||||
displayName: type,
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes,
|
||||
linkedNodes: {},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const containerState = (props: Record<string, unknown>) =>
|
||||
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<string, unknown>) =>
|
||||
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 <query>{ <selector>{...} }` rule for `selector`, or null. */
|
||||
function ruleFor(html: string, device: 'tablet' | 'mobile', selector: string): string | null {
|
||||
const m = /<style data-wsb-responsive>([\s\S]*?)<\/style>/.exec(html);
|
||||
if (!m) return null;
|
||||
const css = m[1];
|
||||
const query = device === 'tablet' ? '@media (max-width:991px){' : '@media (max-width:767px){';
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const i = css.indexOf(query, from);
|
||||
if (i === -1) return null;
|
||||
// media block body runs until the matching '}}' that closes the last rule
|
||||
const bodyStart = i + query.length;
|
||||
const end = css.indexOf('}}', bodyStart);
|
||||
const body = css.slice(bodyStart, end + 1);
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let r: RegExpExecArray | null;
|
||||
while ((r = re.exec(body))) {
|
||||
if (r[1] === selector) return r[2];
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Container export: derived flex in @media rules (finding 7)', () => {
|
||||
test('A: desktop textAlign unset, mobile center → mobile rule gets display:flex;flex-direction:column;align-items:center', () => {
|
||||
const { html } = exportBodyHtml(containerState({ style: {}, styleMobile: { textAlign: 'center' } }));
|
||||
const rule = ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]');
|
||||
expect(rule).not.toBeNull();
|
||||
expect(rule).toContain('text-align:center!important');
|
||||
expect(rule).toContain('display:flex!important');
|
||||
expect(rule).toContain('flex-direction:column!important');
|
||||
expect(rule).toContain('align-items:center!important');
|
||||
});
|
||||
|
||||
test('B: desktop center, mobile left → mobile rule overrides align-items to flex-start', () => {
|
||||
const { html } = exportBodyHtml(containerState({ style: { textAlign: 'center' }, styleMobile: { textAlign: 'left' } }));
|
||||
expect(html).toContain('align-items:center'); // desktop inline flex
|
||||
const rule = ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]');
|
||||
expect(rule).toContain('align-items:flex-start!important');
|
||||
});
|
||||
|
||||
test('desktop center, mobile justify → mobile rule resets the flex layout (editor drops it)', () => {
|
||||
const { html } = exportBodyHtml(containerState({ style: { textAlign: 'center' }, styleMobile: { textAlign: 'justify' } }));
|
||||
const rule = ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]');
|
||||
expect(rule).toContain('display:block!important');
|
||||
expect(rule).toContain('align-items:normal!important');
|
||||
});
|
||||
|
||||
test('C: vertical-align set only on mobile → mobile rule gets display:flex;flex-direction:column', () => {
|
||||
const { html } = exportBodyHtml(containerState({ style: { minHeight: '300px' }, styleMobile: { justifyContent: 'center' } }));
|
||||
const rule = ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]');
|
||||
expect(rule).toContain('justify-content:center!important');
|
||||
expect(rule).toContain('display:flex!important');
|
||||
expect(rule).toContain('flex-direction:column!important');
|
||||
});
|
||||
|
||||
test('cascade: desktop center, tablet left, mobile center → tablet flex-start, mobile back to center', () => {
|
||||
const { html } = exportBodyHtml(containerState({
|
||||
style: { textAlign: 'center' },
|
||||
styleTablet: { textAlign: 'left' },
|
||||
styleMobile: { textAlign: 'center' },
|
||||
}));
|
||||
expect(ruleFor(html, 'tablet', '[data-wsb-r="b-c1"]')).toContain('align-items:flex-start!important');
|
||||
expect(ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]')).toContain('align-items:center!important');
|
||||
});
|
||||
|
||||
test('boxed: mobile alignment targets the inner div that carries the flex, not the outer', () => {
|
||||
const { html } = exportBodyHtml(containerState({ contentWidth: 'boxed', style: {}, styleMobile: { textAlign: 'center' } }));
|
||||
const inner = ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]>div');
|
||||
expect(inner).not.toBeNull();
|
||||
expect(inner).toContain('display:flex!important');
|
||||
expect(inner).toContain('flex-direction:column!important');
|
||||
expect(inner).toContain('align-items:center!important');
|
||||
const outer = ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]') || '';
|
||||
expect(outer).not.toContain('align-items');
|
||||
});
|
||||
|
||||
test('boxed B: desktop center, mobile left → inner div align-items:flex-start', () => {
|
||||
const { html } = exportBodyHtml(containerState({ contentWidth: 'boxed', style: { textAlign: 'center' }, styleMobile: { textAlign: 'left' } }));
|
||||
expect(ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]>div')).toContain('align-items:flex-start!important');
|
||||
});
|
||||
|
||||
test('no alignment overrides → no derived declarations (padding-only override unchanged)', () => {
|
||||
const { html } = exportBodyHtml(containerState({ style: { textAlign: 'center' }, styleMobile: { padding: '4px' } }));
|
||||
expect(ruleFor(html, 'mobile', '[data-wsb-r="b-c1"]')).toBe('padding:4px!important');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section export: derived flex in @media rules (finding 7)', () => {
|
||||
test('C: vertical-align set only on mobile → section mobile rule gets display:flex;flex-direction:column', () => {
|
||||
const { html } = exportBodyHtml(sectionState({ style: { minHeight: '400px' }, styleMobile: { justifyContent: 'center' } }));
|
||||
const rule = ruleFor(html, 'mobile', '[data-wsb-r="b-s1"]');
|
||||
expect(rule).toContain('justify-content:center!important');
|
||||
expect(rule).toContain('display:flex!important');
|
||||
expect(rule).toContain('flex-direction:column!important');
|
||||
});
|
||||
|
||||
test('vertical-align on tablet only → tablet rule flex (mobile inherits via max-width cascade)', () => {
|
||||
const { html } = exportBodyHtml(sectionState({ style: {}, styleTablet: { justifyContent: 'flex-end' } }));
|
||||
const rule = ruleFor(html, 'tablet', '[data-wsb-r="b-s1"]');
|
||||
expect(rule).toContain('display:flex!important');
|
||||
expect(rule).toContain('flex-direction:column!important');
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- Editor: mobile-resolved render carries the same flex ---------- */
|
||||
|
||||
let h: EditorHarness | null = null;
|
||||
afterEach(() => {
|
||||
h?.unmount();
|
||||
h = null;
|
||||
});
|
||||
|
||||
function mount(initialState: string, device: DeviceMode) {
|
||||
h = renderEditorHarness({
|
||||
initialState,
|
||||
onRender: RenderNode,
|
||||
wrap: (tree) => <DeviceProvider initialDevice={device}>{tree}</DeviceProvider>,
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
describe('Container/Section editor render on mobile (finding 7)', () => {
|
||||
test('A: desktop unset, mobile center → flex column align-items center', () => {
|
||||
mount(containerState({ style: {}, styleMobile: { textAlign: 'center' } }), 'mobile');
|
||||
const el = h!.container.querySelector('#c1') as HTMLElement;
|
||||
expect(el.style.display).toBe('flex');
|
||||
expect(el.style.flexDirection).toBe('column');
|
||||
expect(el.style.alignItems).toBe('center');
|
||||
});
|
||||
|
||||
test('B: desktop center, mobile left → align-items flex-start; desktop stays center', () => {
|
||||
mount(containerState({ style: { textAlign: 'center' }, styleMobile: { textAlign: 'left' } }), 'mobile');
|
||||
expect((h!.container.querySelector('#c1') as HTMLElement).style.alignItems).toBe('flex-start');
|
||||
h!.unmount();
|
||||
mount(containerState({ style: { textAlign: 'center' }, styleMobile: { textAlign: 'left' } }), 'desktop');
|
||||
expect((h!.container.querySelector('#c1') as HTMLElement).style.alignItems).toBe('center');
|
||||
});
|
||||
|
||||
test('C: vertical-align only on mobile → flex column', () => {
|
||||
mount(containerState({ style: { minHeight: '300px' }, styleMobile: { justifyContent: 'center' } }), 'mobile');
|
||||
const el = h!.container.querySelector('#c1') as HTMLElement;
|
||||
expect(el.style.display).toBe('flex');
|
||||
expect(el.style.flexDirection).toBe('column');
|
||||
expect(el.style.justifyContent).toBe('center');
|
||||
});
|
||||
|
||||
test('boxed: mobile center → flex on the inner div, not the outer', () => {
|
||||
mount(containerState({ contentWidth: 'boxed', style: {}, styleMobile: { textAlign: 'center' } }), 'mobile');
|
||||
const el = h!.container.querySelector('#c1') as HTMLElement;
|
||||
const inner = el.firstElementChild as HTMLElement;
|
||||
expect(el.style.alignItems).toBe('');
|
||||
expect(inner.style.display).toBe('flex');
|
||||
expect(inner.style.alignItems).toBe('center');
|
||||
});
|
||||
|
||||
test('Section C: vertical-align only on mobile → section is flex column', () => {
|
||||
mount(sectionState({ style: { minHeight: '400px' }, styleMobile: { justifyContent: 'center' } }), 'mobile');
|
||||
const sec = h!.container.querySelector('#s1') as HTMLElement;
|
||||
expect(sec.style.display).toBe('flex');
|
||||
expect(sec.style.flexDirection).toBe('column');
|
||||
expect(sec.style.justifyContent).toBe('center');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { componentResolver } from '../components/resolver';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { cssPropsToString } from './style-helpers';
|
||||
import { escapeHtml, escapeAttr, cssValue, safeImageUrl } from './escape';
|
||||
import { sanitizeContainerTag } from '../components/layout/Container';
|
||||
@@ -162,7 +163,15 @@ function renderNode(
|
||||
// The scope is zone-prefixed (h-/b-/f-): every zone's root is the Craft id
|
||||
// 'ROOT', and header + body + footer end up in ONE published document.
|
||||
const scope = responsiveScopeId(scopePrefix + nodeId);
|
||||
const responsiveCss = responsiveCssForNode(scope, props);
|
||||
const component = resolver[typeName];
|
||||
// Components that derive layout from their style (Container/Section:
|
||||
// alignment → flex) expose `responsiveLayout` so the @media rules carry the
|
||||
// layout derived from the RESOLVED per-device style, as the editor shows it.
|
||||
const derive =
|
||||
component && typeof component.responsiveLayout === 'function'
|
||||
? (resolved: CSSProperties) => component.responsiveLayout(resolved, props)
|
||||
: undefined;
|
||||
const responsiveCss = responsiveCssForNode(scope, props, derive);
|
||||
if (responsiveCss) {
|
||||
cssAcc.push(responsiveCss);
|
||||
dataAttrs += ` data-wsb-r="${scope}"`;
|
||||
@@ -178,7 +187,6 @@ function renderNode(
|
||||
// two instances share default/identical content). Components that don't
|
||||
// need a scope id simply ignore the extra argument -- backward compatible
|
||||
// with existing 2-arg `toHtml(props, childrenHtml)` implementations/tests.
|
||||
const component = resolver[typeName];
|
||||
if (component && typeof component.toHtml === 'function') {
|
||||
const result = component.toHtml(props, allChildrenHtml, nodeId);
|
||||
const html = result.html || '';
|
||||
|
||||
@@ -158,21 +158,88 @@ function declaration(key: string, value: unknown): string {
|
||||
return decl ? `${decl}!important` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional per-component hook (`Component.responsiveLayout`): given a
|
||||
* device-RESOLVED style, the layout declarations the component derives from
|
||||
* it (e.g. Container: textAlign → display:flex;flex-direction:column;
|
||||
* align-items), keyed by selector suffix relative to the node's scope --
|
||||
* `''` for the scoped element itself, `'>div'` for a wrapper child that
|
||||
* carries the flex. The editor render derives the same values from the
|
||||
* resolved style, so the @media rules must too (finding 7).
|
||||
*/
|
||||
export type DerivedLayoutFn = (resolvedStyle: CSSProperties) => Record<string, CSSProperties>;
|
||||
|
||||
/** Value that undoes a derived declaration the previous breakpoint set but
|
||||
* this one no longer derives (e.g. desktop centred, mobile 'justify'). */
|
||||
const DERIVED_RESET: Record<string, string> = {
|
||||
display: 'block',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'normal',
|
||||
justifyContent: 'normal',
|
||||
};
|
||||
|
||||
/**
|
||||
* The derived declarations to emit for `device`: everything that differs
|
||||
* from what already applies at that width (desktop inline style for tablet;
|
||||
* the tablet rule -- `max-width:991px` also matches mobile -- for mobile),
|
||||
* plus any derived key the device's raw override also sets (the derived
|
||||
* value wins in the editor, so it must come after the raw one here too).
|
||||
*/
|
||||
function derivedRules(
|
||||
derive: DerivedLayoutFn,
|
||||
props: ResponsiveProps,
|
||||
device: OverrideDevice,
|
||||
rawKeys: Set<string>,
|
||||
): Record<string, StyleRecord> {
|
||||
const prevDevice: DeviceMode = device === 'tablet' ? 'desktop' : 'tablet';
|
||||
const cur = derive(resolveStyleForDevice(props, device)) || {};
|
||||
const prev = derive(resolveStyleForDevice(props, prevDevice)) || {};
|
||||
const out: Record<string, StyleRecord> = {};
|
||||
for (const suffix of new Set([...Object.keys(cur), ...Object.keys(prev)])) {
|
||||
const c = asRecord(cur[suffix]);
|
||||
const p = asRecord(prev[suffix]);
|
||||
const decls: StyleRecord = {};
|
||||
for (const [k, v] of Object.entries(c)) {
|
||||
if (!isSet(v)) continue;
|
||||
if (v !== p[k] || (suffix === '' && rawKeys.has(k))) decls[k] = v;
|
||||
}
|
||||
for (const [k, v] of Object.entries(p)) {
|
||||
if (isSet(v) && !isSet(c[k]) && DERIVED_RESET[k]) decls[k] = DERIVED_RESET[k];
|
||||
}
|
||||
if (Object.keys(decls).length > 0) out[suffix] = decls;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Only the selector suffixes the exporter knows how to scope. */
|
||||
const SAFE_SUFFIX = /^(>[a-z]+)?$/;
|
||||
|
||||
/**
|
||||
* Scoped CSS for a node's overrides, or `''` when it has none:
|
||||
* `@media (max-width:991px){[data-wsb-r="<id>"]{k:v!important;…}}@media (max-width:767px){…}`.
|
||||
* `!important` is required because the base value is an inline `style=""`.
|
||||
* With `derive` (the component's `responsiveLayout`), the layout it derives
|
||||
* from the resolved per-device style is emitted in the same @media block.
|
||||
*/
|
||||
export function responsiveCssForNode(scopeAttr: string, props: ResponsiveProps): string {
|
||||
export function responsiveCssForNode(scopeAttr: string, props: ResponsiveProps, derive?: DerivedLayoutFn): string {
|
||||
const scope = responsiveScopeId(scopeAttr);
|
||||
if (!scope) return '';
|
||||
let css = '';
|
||||
for (const device of ['tablet', 'mobile'] as const) {
|
||||
const decls = Object.entries(setEntries(props[OVERRIDE_KEY[device]]))
|
||||
const raw = setEntries(props[OVERRIDE_KEY[device]]);
|
||||
if (Object.keys(raw).length === 0) continue;
|
||||
const derived = derive ? derivedRules(derive, props, device, new Set(Object.keys(raw))) : {};
|
||||
const rootDecls = [...Object.entries(raw), ...Object.entries(derived[''] || {})]
|
||||
.map(([k, v]) => declaration(k, v))
|
||||
.filter(Boolean);
|
||||
if (decls.length === 0) continue;
|
||||
css += `@media ${mediaQueryFor(device)}{[data-wsb-r="${scope}"]{${decls.join(';')}}}`;
|
||||
const sel = `[data-wsb-r="${scope}"]`;
|
||||
let rules = rootDecls.length > 0 ? `${sel}{${rootDecls.join(';')}}` : '';
|
||||
for (const [suffix, decls] of Object.entries(derived)) {
|
||||
if (suffix === '' || !SAFE_SUFFIX.test(suffix)) continue;
|
||||
const d = Object.entries(decls).map(([k, v]) => declaration(k, v)).filter(Boolean);
|
||||
if (d.length > 0) rules += `${sel}${suffix}{${d.join(';')}}`;
|
||||
}
|
||||
if (rules) css += `@media ${mediaQueryFor(device)}{${rules}}`;
|
||||
}
|
||||
return css;
|
||||
}
|
||||
Reference in new issue
Block a user