fix(site-builder): remaining style panels show per-device values and dots
SectionType, Social, Pricing, Form and the generic props editor already wrote tablet/mobile overrides through the device-aware useNodeProp setters but still read nodeProps.style, so a mobile padding/margin/alignment edit looked like a no-op and had no reset dot. They now read useResolvedStyle() and show OverrideDots on Padding, Margin and Text Alignment. NavStylePanel has the same gap but is outside this task's files. Refs #45 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c2a90cd7a7
commit
c472560fb5
6 files changed
+104
-19
No files matched your search
@@ -28,6 +28,8 @@ import {
|
||||
useNodeProp,
|
||||
continuousSetProp,
|
||||
} from './shared';
|
||||
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
|
||||
import { OverrideDot } from './OverrideDot';
|
||||
|
||||
/* The full sanitizeInputType (utils/escape.ts) allowlist, plus the two
|
||||
fake "types" (textarea/select) that take their own ContactForm render
|
||||
@@ -175,7 +177,10 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
// Effective value for the current device (#45): the setters write
|
||||
// tablet/mobile overrides, so the fields must read them back.
|
||||
const style = useResolvedStyle(nodeProps) as Record<string, any>;
|
||||
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
|
||||
// ContactForm only: FormContainer/SubscribeForm have no destinationType prop,
|
||||
// so their relay controls stay exactly as they were.
|
||||
const isWebhook = nodeProps.destinationType === 'webhook';
|
||||
@@ -484,7 +489,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
@@ -498,12 +503,12 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
they all spread `style` onto their root element. */}
|
||||
<CollapsibleSection title="Spacing, Border & Effects">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
label={<>Margin{dot(MARGIN_KEYS)}</>}
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
onChange={(side, v) => setPropStyleContinuous(`margin${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding (per side)"
|
||||
label={<>Padding (per side){dot(PADDING_KEYS)}</>}
|
||||
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
||||
onChange={(side, v) => setPropStyleContinuous(`padding${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
import { useResolvedStyle, PADDING_KEYS } from './responsive-panel';
|
||||
import { OverrideDot } from './OverrideDot';
|
||||
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
||||
import { HtmlCodeField } from './HtmlCodeField';
|
||||
|
||||
@@ -35,7 +37,10 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
|
||||
const stringProps = allProps.filter(([key, val]) => typeof val === 'string' && !/color/i.test(key));
|
||||
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
// Effective value for the current device (#45): the setters write
|
||||
// tablet/mobile overrides, so the fields must read them back.
|
||||
const style = useResolvedStyle(nodeProps) as Record<string, any>;
|
||||
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
|
||||
const hasCodeProp = typeof nodeProps.code === 'string';
|
||||
|
||||
return (
|
||||
@@ -117,11 +122,11 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
|
||||
<ColorSwatchGrid colors={TEXT_COLORS} activeValue={style.color as string} onSelect={(v: string) => setStyleValue('color', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setStyleValue('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Alignment</SectionLabel>
|
||||
<SectionLabel>Text Alignment{dot('textAlign')}</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setStyleValue('textAlign', a)} title={a}>
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
VisibilityControl,
|
||||
continuousSetProp,
|
||||
} from './shared';
|
||||
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
|
||||
import { OverrideDot } from './OverrideDot';
|
||||
|
||||
/** Parses a border shorthand string ("2px solid #hex") produced by
|
||||
* buildBorderShorthand() back into its parts for round-tripping through
|
||||
@@ -54,7 +56,10 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
|
||||
|
||||
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
|
||||
const currentBullet = nodeProps.bulletType || 'check';
|
||||
const style = nodeProps.style || {};
|
||||
// Effective value for the current device (#45): the setters write
|
||||
// tablet/mobile overrides, so the fields must read them back.
|
||||
const style = useResolvedStyle(nodeProps) as Record<string, any>;
|
||||
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
|
||||
|
||||
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
|
||||
continuousSetProp(actions, selectedId, `plan:${planIndex}:${field}`, (props: any) => {
|
||||
@@ -244,7 +249,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
|
||||
{/* Box model: margin, padding, border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
label={<>Margin{dot(MARGIN_KEYS)}</>}
|
||||
value={{
|
||||
top: style.marginTop as string, right: style.marginRight as string,
|
||||
bottom: style.marginBottom as string, left: style.marginLeft as string,
|
||||
@@ -252,7 +257,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
|
||||
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
label={<>Padding{dot(PADDING_KEYS)}</>}
|
||||
value={{
|
||||
top: style.paddingTop as string, right: style.paddingRight as string,
|
||||
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
} from './shared';
|
||||
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
|
||||
import { OverrideDot } from './OverrideDot';
|
||||
import { FeaturesEditor } from './FeaturesEditor';
|
||||
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
||||
|
||||
@@ -45,7 +47,10 @@ const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
||||
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
// Effective value for the current device (#45): the setters write
|
||||
// tablet/mobile overrides, so the fields must read them back.
|
||||
const style = useResolvedStyle(nodeProps) as Record<string, any>;
|
||||
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
|
||||
|
||||
// Find all string/number/boolean props
|
||||
const SKIP_PROPS = new Set([
|
||||
@@ -135,11 +140,11 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Text Alignment</SectionLabel>
|
||||
<SectionLabel>Text Alignment{dot('textAlign')}</SectionLabel>
|
||||
<div className="preset-grid align-grid">
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setPropStyle('textAlign', a)} title={a}>
|
||||
@@ -157,7 +162,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
||||
{/* Box model: margin, padding, border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
label={<>Margin{dot(MARGIN_KEYS)}</>}
|
||||
value={{
|
||||
top: style.marginTop as string, right: style.marginRight as string,
|
||||
bottom: style.marginBottom as string, left: style.marginLeft as string,
|
||||
@@ -165,7 +170,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
||||
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
label={<>Padding{dot(PADDING_KEYS)}</>}
|
||||
value={{
|
||||
top: style.paddingTop as string, right: style.paddingRight as string,
|
||||
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
VisibilityControl,
|
||||
continuousSetProp,
|
||||
} from './shared';
|
||||
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
|
||||
import { OverrideDot } from './OverrideDot';
|
||||
|
||||
/** Parses a border shorthand string ("2px solid #hex") produced by
|
||||
* buildBorderShorthand() back into its parts for round-tripping through
|
||||
@@ -51,7 +53,10 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
|
||||
|
||||
const style = nodeProps.style || {};
|
||||
// Effective value for the current device (#45): the setters write
|
||||
// tablet/mobile overrides, so the fields must read them back.
|
||||
const style = useResolvedStyle(nodeProps) as Record<string, any>;
|
||||
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -253,7 +258,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
|
||||
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
@@ -261,7 +266,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
{/* Box model: margin, padding (per-side), border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
label={<>Margin{dot(MARGIN_KEYS)}</>}
|
||||
value={{
|
||||
top: style.marginTop as string, right: style.marginRight as string,
|
||||
bottom: style.marginBottom as string, left: style.marginLeft as string,
|
||||
@@ -269,7 +274,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
onChange={(side, v) => setPropStyleContinuous(`margin${capSide(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
label={<>Padding{dot(PADDING_KEYS)}</>}
|
||||
value={{
|
||||
top: style.paddingTop as string, right: style.paddingRight as string,
|
||||
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
|
||||
|
||||
@@ -9,6 +9,11 @@ import type { DeviceMode } from '../../../types';
|
||||
import { GuidedStyles } from '../GuidedStyles';
|
||||
import { TextStylePanel } from './TextStylePanel';
|
||||
import { ContainerStylePanel } from './ContainerStylePanel';
|
||||
import { SocialStylePanel } from './SocialStylePanel';
|
||||
import { SectionTypePanel } from './SectionTypePanel';
|
||||
import { PricingStylePanel } from './PricingStylePanel';
|
||||
import { FormStylePanel } from './FormStylePanel';
|
||||
import { GenericPropsEditor } from './GenericPropsEditor';
|
||||
import {
|
||||
useNodeProp,
|
||||
closeContinuousBurst,
|
||||
@@ -315,6 +320,61 @@ describe('DeviceBanner in GuidedStyles', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('every useNodeProp panel reads back what it writes per device (fix round 1)', () => {
|
||||
// Panels that edit responsive keys through the device-aware setters must
|
||||
// show the RESOLVED value and a dot, or a mobile edit looks like a no-op.
|
||||
const blockState = () =>
|
||||
JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['blk']),
|
||||
blk: node('Container', { tag: 'div', style: { padding: '8px', marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0', textAlign: 'left' } }, [], { parent: 'ROOT' }),
|
||||
});
|
||||
const withType = (P: React.FC<any>, typeName: string): React.FC<any> => (p) => <P {...p} typeName={typeName} />;
|
||||
const panels: [string, React.FC<any>, { preset: boolean; spacing: boolean; align?: string }][] = [
|
||||
['SocialStylePanel', SocialStylePanel, { preset: true, spacing: true }],
|
||||
['SectionTypePanel', withType(SectionTypePanel, 'Accordion'), { preset: true, spacing: true, align: 'Text Alignment' }],
|
||||
['PricingStylePanel', PricingStylePanel, { preset: false, spacing: true }],
|
||||
['FormStylePanel', FormStylePanel, { preset: true, spacing: true }],
|
||||
['GenericPropsEditor', withType(GenericPropsEditor, 'Thing'), { preset: true, spacing: false, align: 'Text Alignment' }],
|
||||
];
|
||||
|
||||
const marginControl = () =>
|
||||
Array.from(h!.container.querySelectorAll('[data-testid="spacing-control"]'))
|
||||
.find((el) => (el.querySelector('.guided-section-label')?.textContent || '').startsWith('Margin')) as HTMLElement;
|
||||
|
||||
test.each(panels)('%s: mobile edits land in styleMobile and the panel shows them with a dot', (_n, Panel, has) => {
|
||||
mountPanel('mobile', Panel, 'blk', blockState());
|
||||
if (has.preset) {
|
||||
expect(activePreset('Padding')).toBe('S');
|
||||
expect(hollowDot('Padding')).not.toBeNull();
|
||||
click(presetBtn('Padding', 'L'));
|
||||
expect(props('blk').styleMobile?.padding).toBe('24px');
|
||||
expect(props('blk').style.padding).toBe('8px');
|
||||
expect(activePreset('Padding')).toBe('L');
|
||||
expect(filledDot('Padding')).not.toBeNull();
|
||||
}
|
||||
if (has.spacing) {
|
||||
const m = marginControl();
|
||||
expect(m.querySelector('button.preset-btn.active')?.textContent).toBe('None');
|
||||
click(Array.from(m.querySelectorAll('button.preset-btn')).find((b) => b.textContent === 'M')!);
|
||||
expect(props('blk').styleMobile?.marginTop).toBe('16px');
|
||||
expect(props('blk').style.marginTop).toBe('0');
|
||||
expect(marginControl().querySelector('button.preset-btn.active')?.textContent).toBe('M');
|
||||
const dot = marginControl().querySelector('[data-testid="override-dot"]') as HTMLElement;
|
||||
expect(dot).not.toBeNull();
|
||||
click(dot);
|
||||
expect(props('blk').styleMobile?.marginTop).toBeUndefined();
|
||||
expect(marginControl().querySelector('button.preset-btn.active')?.textContent).toBe('None');
|
||||
}
|
||||
if (has.align) {
|
||||
click(section(has.align).querySelector('button[title="center"]')!);
|
||||
expect(props('blk').styleMobile?.textAlign).toBe('center');
|
||||
expect(props('blk').style.textAlign).toBe('left');
|
||||
expect(section(has.align).querySelector('button.preset-btn.active')?.getAttribute('title')).toBe('center');
|
||||
expect(filledDot(has.align)).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('end to end: export', () => {
|
||||
test('a mobile font-size edit exports as a scoped max-width:767px rule', () => {
|
||||
mountPanel('mobile');
|
||||
|
||||
Reference in new issue
Block a user