feat(builder): containers package -- vertical alignment + box-model/anim/vis rollout

- ColumnLayout: exposes style.alignItems on its flex ROW (aligns uneven
  columns) -- render/toHtml already spread `style` onto the row div, so this
  is a craft.props default + panel control addition, no structural change.
- Container/Section: root element is now unconditionally display:flex;
  flex-direction:column (both editor render and toHtml), so the new
  Vertical Alignment control maps to style.justifyContent, paired with a
  Min Height (NumericUnitInput) control on style.minHeight. Default
  justify-content/align-items reproduce ordinary block-flow stacking, so
  this is a visual no-op for existing published content. Works in both
  normal and "boxed" (contentWidth) modes -- the boxed inner wrapper's own
  margin:0-auto horizontal centering is preserved via flex auto-margin
  override semantics.
- ContainerStylePanel (serves Container/Section/Columns) distinguishes the
  Columns case from Container/Section via nodeProps.columns/split presence
  (no typeName plumbing needed) to pick align-items vs justify-content for
  the shared Vertical Alignment control.
- All 3 owned components: added margin/padding (per-side)/border/box-shadow/
  opacity style defaults + AnimationControl/VisibilityControl-backed
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props.
  New containerBoxModel.tsx (package-local, not shared.tsx) DRYs the
  box-model + border/effects + animation/visibility panel sections across
  the single shared ContainerStylePanel, mirroring the sibling media
  package's mediaBoxModel.tsx.
- Tests: extended all 3 *.toHtml.test.ts files (align-items/justify-content/
  min-height emission, box-model style emission, craft.props presence).
  673 tests green, tsc + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 06:44:37 -07:00
parent 1d9460c173
commit 9750a6c2bf
8 changed files with 445 additions and 4 deletions
@@ -80,3 +80,56 @@ describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
expect(html).toMatch(/calc\(50% - 24px\)/); expect(html).toMatch(/calc\(50% - 24px\)/);
}); });
}); });
describe('ColumnLayout.toHtml vertical alignment (align-items on the flex row)', () => {
test('style.alignItems flows into the emitted style attribute (aligns uneven columns)', () => {
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px', style: { alignItems: 'center' } }, '<div>A</div><div>B</div>');
expect(html).toContain('align-items:center');
});
});
describe('ColumnLayout.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
columns: 2,
split: '50-50',
gap: '16px',
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'<div>A</div><div>B</div>',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('ColumnLayout.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (ColumnLayout as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default alignItems and box-model keys', () => {
const style = (ColumnLayout as any).craft.props.style;
expect(style).toHaveProperty('alignItems');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+18 -1
View File
@@ -20,6 +20,11 @@ interface ColumnLayoutProps {
style?: CSSProperties; style?: CSSProperties;
children?: React.ReactNode; children?: React.ReactNode;
anchorId?: string; anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
const splitToWidths: Record<string, string[]> = { const splitToWidths: Record<string, string[]> = {
@@ -102,8 +107,20 @@ ColumnLayout.craft = {
columns: 2, columns: 2,
split: '50-50', split: '50-50',
gap: '16px', gap: '16px',
style: {}, style: {
alignItems: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
anchorId: '', anchorId: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -63,3 +63,71 @@ 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)', () => {
const { html } = toHtml({}, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
});
test('style.justifyContent flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('justify-content:center');
});
test('style.minHeight flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
expect(html).toContain('min-height:400px');
});
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('justify-content:flex-end');
expect(html).toContain('min-height:500px');
});
});
describe('Container.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'child',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('Container.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Container as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default vertical-alignment and box-model keys', () => {
const style = (Container as any).craft.props.style;
expect(style).toHaveProperty('justifyContent');
expect(style).toHaveProperty('minHeight');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+31 -1
View File
@@ -43,6 +43,18 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
return {}; 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.
export const Container: UserComponent<ContainerProps> = ({ export const Container: UserComponent<ContainerProps> = ({
style = {}, style = {},
tag = 'div', tag = 'div',
@@ -60,6 +72,8 @@ export const Container: UserComponent<ContainerProps> = ({
const flexStyles = flexAlignFromTextAlign(style.textAlign); const flexStyles = flexAlignFromTextAlign(style.textAlign);
const outerStyle: CSSProperties = { const outerStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
minHeight: '40px', minHeight: '40px',
...style, ...style,
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
@@ -93,13 +107,27 @@ export const Container: UserComponent<ContainerProps> = ({
Container.craft = { Container.craft = {
displayName: 'Container', displayName: 'Container',
props: { props: {
style: { padding: '20px', minHeight: '100px' }, style: {
padding: '20px',
minHeight: '100px',
justifyContent: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
tag: 'div', tag: 'div',
fullWidth: false, fullWidth: false,
contentWidth: 'full', contentWidth: 'full',
anchorId: '', anchorId: '',
cssId: '', cssId: '',
cssClass: '', cssClass: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -116,6 +144,8 @@ Container.craft = {
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
const outerCss: CSSProperties = { const outerCss: CSSProperties = {
display: 'flex',
flexDirection: 'column',
...props.style, ...props.style,
...(isBoxed ? {} : flexStyles), ...(isBoxed ? {} : flexStyles),
}; };
@@ -73,3 +73,65 @@ describe('Section.toHtml shape divider color/height XSS hardening', () => {
expect(html).not.toContain('<svg'); expect(html).not.toContain('<svg');
}); });
}); });
describe('Section.toHtml vertical alignment (justify-content + min-height)', () => {
test('is always a column flex container (display:flex;flex-direction:column)', () => {
const { html } = toHtml({}, 'child');
expect(html).toContain('display:flex');
expect(html).toContain('flex-direction:column');
});
test('style.justifyContent flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
expect(html).toContain('justify-content:center');
});
test('style.minHeight flows into the emitted style attribute', () => {
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
expect(html).toContain('min-height:600px');
});
});
describe('Section.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
const { html } = toHtml(
{
style: {
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
paddingTop: '5px',
border: '2px solid #ff0000',
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
opacity: '0.8',
},
},
'child',
);
expect(html).toContain('margin-top:10px');
expect(html).toContain('padding-top:5px');
expect(html).toContain('border:2px solid #ff0000');
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
expect(html).toContain('opacity:0.8');
});
});
describe('Section.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
const props = (Section as any).craft.props;
expect(props.animation).toBe('');
expect(props.animationDelay).toBe('0');
expect(props.hideOnDesktop).toBe(false);
expect(props.hideOnTablet).toBe(false);
expect(props.hideOnMobile).toBe(false);
});
test('style carries blank/default vertical-alignment and box-model keys', () => {
const style = (Section as any).craft.props.style;
expect(style).toHaveProperty('justifyContent');
expect(style).toHaveProperty('minHeight');
expect(style).toHaveProperty('marginTop');
expect(style).toHaveProperty('paddingTop');
expect(style.border).toBe('none');
expect(style.boxShadow).toBe('none');
expect(style.opacity).toBe('1');
});
});
+31 -1
View File
@@ -27,6 +27,11 @@ interface SectionProps {
bottomDividerColor?: string; bottomDividerColor?: string;
bottomDividerHeight?: string; bottomDividerHeight?: string;
anchorId?: string; anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
} }
/* ---------- Divider renderer ---------- */ /* ---------- Divider renderer ---------- */
@@ -105,6 +110,14 @@ 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,
}} }}
@@ -143,7 +156,17 @@ export const Section: UserComponent<SectionProps> = ({
Section.craft = { Section.craft = {
displayName: 'Section', displayName: 'Section',
props: { props: {
style: { padding: '40px 0', backgroundColor: '#ffffff' }, style: {
padding: '40px 0',
backgroundColor: '#ffffff',
minHeight: '',
justifyContent: '',
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
border: 'none',
boxShadow: 'none',
opacity: '1',
},
innerMaxWidth: '1200px', innerMaxWidth: '1200px',
topDivider: 'none', topDivider: 'none',
topDividerColor: '#ffffff', topDividerColor: '#ffffff',
@@ -152,6 +175,11 @@ Section.craft = {
bottomDividerColor: '#ffffff', bottomDividerColor: '#ffffff',
bottomDividerHeight: '50px', bottomDividerHeight: '50px',
anchorId: '', anchorId: '',
animation: '',
animationDelay: '0',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
}, },
rules: { rules: {
canDrag: () => true, canDrag: () => true,
@@ -202,6 +230,8 @@ function buildDividerHtml(
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,
}); });
@@ -10,18 +10,40 @@ import {
ColorSwatchGrid, ColorSwatchGrid,
GradientSwatchGrid, GradientSwatchGrid,
PresetButtonGrid, PresetButtonGrid,
NumericUnitInput,
labelStyle, labelStyle,
inputStyle, inputStyle,
sectionGap, sectionGap,
useNodeProp, useNodeProp,
} from './shared'; } from './shared';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './containerBoxModel';
/* ---------- CONTAINER / SECTION ---------- */ // Vertical Alignment options shown to the user identically regardless of
// which CSS property they end up mapped to (align-items for the Columns
// flex ROW vs. justify-content for Container/Section's flex COLUMN root --
// see the per-type branch below).
const VERTICAL_ALIGN_OPTIONS: { label: string; value: string }[] = [
{ label: 'Top', value: 'flex-start' },
{ label: 'Center', value: 'center' },
{ label: 'Bottom', value: 'flex-end' },
{ label: 'Stretch', value: 'stretch' },
];
/* ---------- CONTAINER / SECTION / COLUMNS ---------- */
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => { export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const style: CSSProperties = nodeProps.style || {}; const style: CSSProperties = nodeProps.style || {};
const { setProp, setPropStyle } = useNodeProp(selectedId); const { setProp, setPropStyle } = useNodeProp(selectedId);
// ColumnLayout only ever carries `columns`/`split` props -- Container and
// Section never set them -- so checking either alone distinguishes the
// flex-ROW case (align its columns via align-items, aligning uneven
// column heights) from the flex-COLUMN case (Container/Section, which
// vertically center/position their OWN content via justify-content,
// paired with a Min Height control so centering is meaningful).
const isColumns = nodeProps.columns !== undefined || nodeProps.split !== undefined;
const vAlignKey = isColumns ? 'alignItems' : 'justifyContent';
return ( return (
<> <>
{nodeProps.cssId !== undefined && ( {nodeProps.cssId !== undefined && (
@@ -94,6 +116,30 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
))} ))}
</div> </div>
</div> </div>
<div className="guided-section">
<SectionLabel>Vertical Alignment</SectionLabel>
<PresetButtonGrid
presets={VERTICAL_ALIGN_OPTIONS}
activeValue={style[vAlignKey] as string}
onSelect={(v) => setPropStyle(vAlignKey, v)}
/>
</div>
{!isColumns && (
<div className="guided-section">
<SectionLabel>Min Height</SectionLabel>
<NumericUnitInput
value={(style.minHeight as string) || ''}
onChange={(v) => setPropStyle('minHeight', v)}
units={['px', 'vh', '%']}
placeholder="auto"
/>
</div>
)}
{/* Box model + border/effects + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyle} />
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
</> </>
); );
}; };
@@ -0,0 +1,135 @@
import React from 'react';
import { SHADOW_PRESETS } from '../../../constants/presets';
import {
SectionLabel,
PresetButtonGrid,
CollapsibleSection,
SpacingControl,
SpacingSide,
BorderControl,
BorderValue,
buildBorderShorthand,
AnimationControl,
VisibilityControl,
sectionGap,
labelStyle,
} from './shared';
/* ==========================================================================
Shared box-model / border+effects / animation+visibility sections for the
CONTAINERS package's single shared panel (ContainerStylePanel, used for
Container / Section / Columns). Kept local to this package (not in
shared.tsx, which is foundation/import-only) since it's just DRY-ing the
identical JSX block across those 3 components rather than a genuinely
cross-package reusable control. Mirrors the equivalent helper in the
media package (mediaBoxModel.tsx) -- same shape, independently duplicated
per-package by design (packages are developed and merged in parallel).
========================================================================== */
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
* the {width,style,color} shape BorderControl edits. Only needs to
* round-trip values this same panel produced via buildBorderShorthand --
* not arbitrary author-supplied CSS. */
export function parseBorderShorthand(v: string | undefined): BorderValue {
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '#000000' };
return { width: m[1], style: m[2], color: m[3] };
}
export interface BoxModelSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Margin + Padding, per-side, via the shared SpacingControl. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
setPropStyle(`${kind}${capitalize(side)}`, value);
return (
<CollapsibleSection title="Spacing" defaultOpen={false}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
/>
</CollapsibleSection>
);
};
/** style.opacity is stored as a CSS-length-free numeric string ("0.8") or
* may be blank/undefined (treated as fully opaque). Converts to a 0-100
* integer for the range input / label. */
function opacityPercent(v: unknown): number {
if (v === undefined || v === null || v === '') return 100;
const n = parseFloat(String(v));
return Number.isFinite(n) ? Math.round(n * 100) : 100;
}
export interface BorderEffectsSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
}
/** Border (width/style/color) + box-shadow preset + opacity slider. */
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
<BorderControl
value={parseBorderShorthand(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div style={sectionGap}>
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
<input
type="range"
min={0}
max={100}
value={opacityPercent(style.opacity)}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
);
export interface AnimVisSectionProps {
nodeProps: Record<string, any>;
setProp: (key: string, value: any) => void;
}
/** Entrance animation + responsive hide toggles -- top-level props consumed
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
/>
<VisibilityControl
value={{
hideOnDesktop: nodeProps.hideOnDesktop,
hideOnTablet: nodeProps.hideOnTablet,
hideOnMobile: nodeProps.hideOnMobile,
}}
onChange={(v) => {
setProp('hideOnDesktop', !!v.hideOnDesktop);
setProp('hideOnTablet', !!v.hideOnTablet);
setProp('hideOnMobile', !!v.hideOnMobile);
}}
/>
</CollapsibleSection>
);