diff --git a/craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx b/craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx index 46b33b9..390c9f7 100644 --- a/craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx +++ b/craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx @@ -47,7 +47,7 @@ export const BackgroundSectionStylePanel: React.FC = ({ selecte {/* Layout */} - +
= ({ selectedId, nodePr {/* Box model + border/effects + animation/visibility rollout */} - + = ({ selectedId, nodeProps }) => { const style: CSSProperties = nodeProps.style || {}; + const { actions } = useEditor(); const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId); // ColumnLayout only ever carries `columns`/`split` props -- Container and @@ -41,6 +43,9 @@ export const ContainerStylePanel: React.FC = ({ selectedId, nod // 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 setPropStylesContinuous = (patch: Record, controlKey: string) => + continuousSetProp(actions, selectedId, controlKey, (p: any) => { p.style = { ...p.style, ...patch }; }); + const isColumns = nodeProps.columns !== undefined || nodeProps.split !== undefined; const vAlignKey = isColumns ? 'alignItems' : 'justifyContent'; @@ -85,14 +90,6 @@ export const ContainerStylePanel: React.FC = ({ selectedId, nod onSelect={(v) => setPropStyle('background', v)} />
-
- Padding - setPropStyle('padding', v)} - /> -
Border Radius = ({ selectedId, nod )} {/* Box model + border/effects + animation/visibility rollout */} - + diff --git a/craft/src/panels/right/styles/FormStylePanel.tsx b/craft/src/panels/right/styles/FormStylePanel.tsx index 355c397..6905b16 100644 --- a/craft/src/panels/right/styles/FormStylePanel.tsx +++ b/craft/src/panels/right/styles/FormStylePanel.tsx @@ -478,7 +478,7 @@ export const FormStylePanel: React.FC = ({ selectedId, nodeProp )} {/* Style */} - +
Background setPropStyle('backgroundColor', v)} /> @@ -496,7 +496,7 @@ export const FormStylePanel: React.FC = ({ selectedId, nodeProp {/* Box model: margin/padding (per-side), border, shadow, opacity -- common enh-batch rollout, applies to the whole form family since they all spread `style` onto their root element. */} - + = ({ selectedId, nodePro )} {/* Background & radius */} - +
Background setPropStyle('backgroundColor', v)} /> diff --git a/craft/src/panels/right/styles/MediaStylePanel.video.test.tsx b/craft/src/panels/right/styles/MediaStylePanel.video.test.tsx index 53b0577..025ea58 100644 --- a/craft/src/panels/right/styles/MediaStylePanel.video.test.tsx +++ b/craft/src/panels/right/styles/MediaStylePanel.video.test.tsx @@ -55,6 +55,7 @@ function setValue(input: HTMLInputElement, value: string) { } beforeEach(() => { + try { window.localStorage.clear(); } catch { /* ignore */ } setPropSpy.mockClear(); }); @@ -178,13 +179,14 @@ describe('MediaStylePanel Video AspectRatioControl applies crop-fills-by-default function openCollapsibleByTitle(title: string) { const btn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes(title)); expect(btn).toBeTruthy(); + if (btn!.getAttribute('aria-expanded') === 'true') return; act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); } describe('MediaStylePanel box-model + animation/visibility rollout renders for every media type', () => { - test('a Map-shaped selection still gets Spacing / Border & Effects / Animation & Visibility sections (collapsed by default, open on click)', () => { + test('a Map-shaped selection still gets Spacing / Border & Effects / Animation & Visibility sections (Spacing open by default, others open on click)', () => { lastProps = { address: 'New York, NY', zoom: 14, height: '400px', style: {}, animation: '', hideOnDesktop: false }; render(); diff --git a/craft/src/panels/right/styles/NavStylePanel.test.tsx b/craft/src/panels/right/styles/NavStylePanel.test.tsx index 2cd9689..a636dd1 100644 --- a/craft/src/panels/right/styles/NavStylePanel.test.tsx +++ b/craft/src/panels/right/styles/NavStylePanel.test.tsx @@ -65,6 +65,7 @@ function click(el: Element | null) { } beforeEach(() => { + try { window.localStorage.clear(); } catch { /* ignore */ } setPropSpy.mockClear(); mockPages = [ { id: 'home', name: 'Home', slug: 'index', craftState: null }, @@ -227,14 +228,12 @@ describe('NavStylePanel: "Sync links with Pages" (F2, regression vs legacy build }); }); -/** The Spacing/Border & Effects/Animation/Visibility CollapsibleSections all - * default closed (defaultOpen={false}, matching the pre-existing "Spacing" - * section's convention) -- open one by clicking its header button before - * asserting on / interacting with its contents. */ +/** Border & Effects / Animation / Visibility default closed; Spacing defaults + * open. Opens a section if it is not already, before asserting on it. */ function openSection(title: string) { const header = Array.from(container.querySelectorAll('button')) .find((b) => b.textContent?.includes(title))!; - click(header); + if (header.getAttribute('aria-expanded') !== 'true') click(header); } describe('NavStylePanel: box-model + animation + visibility controls always present', () => { diff --git a/craft/src/panels/right/styles/NavStylePanel.tsx b/craft/src/panels/right/styles/NavStylePanel.tsx index 3d5f1e9..c3e24b8 100644 --- a/craft/src/panels/right/styles/NavStylePanel.tsx +++ b/craft/src/panels/right/styles/NavStylePanel.tsx @@ -405,7 +405,7 @@ export const NavStylePanel: React.FC = ({ selectedId, nodeProps single "Padding" preset row is folded into the Padding SpacingControl below (still writes to style.padding when linked, matching the previous behavior exactly). */} - + = ({ selectedId, nodeP {/* Colors */} - + continuousSetProp(actions, selectedId, 'featuredBg', (p: any) => { p.featuredBg = v; })} /> {/* Regular (non-featured) card colors -- built into PricingTable's render/toHtml but previously hard-coded literals with no control @@ -242,7 +242,7 @@ export const PricingStylePanel: React.FC = ({ selectedId, nodeP {/* Box model: margin, padding, border, shadow, opacity */} - + })} {/* Style */} - +
Background setPropStyle('backgroundColor', v)} /> @@ -155,7 +155,7 @@ export const SectionTypePanel: React.FC {/* Box model: margin, padding, border, shadow, opacity */} - + = ({ selectedId, nodePr )} {/* Background & padding */} - +
Background setPropStyle('backgroundColor', v)} /> @@ -259,7 +259,7 @@ export const SocialStylePanel: React.FC = ({ selectedId, nodePr {/* Box model: margin, padding (per-side), border, shadow, opacity */} - + = ({ selectedId, nodeProp
{/* Box model + border/effects + animation/visibility rollout */} - + ({ + type: { resolvedName: type }, isCanvas: false, props, displayName: type, custom: {}, hidden: false, nodes: [], linkedNodes: {}, ...extra, +}); +const STATE = JSON.stringify({ + ROOT: node('Container', { style: {}, tag: 'div' }, { isCanvas: true, nodes: ['sec', 'head', 'btn'] }), + sec: node('Section', { + style: { padding: '40px 0', paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', marginTop: '' }, + innerMaxWidth: '1200px', + }, { parent: 'ROOT' }), + head: node('Heading', { text: 'Hi', level: 'h2', style: { color: '#000000' } }, { parent: 'ROOT' }), + btn: node('ButtonLink', { text: 'Go', style: {} }, { parent: 'ROOT' }), +}); + +let harness: EditorHarness | null = null; +beforeEach(() => { try { window.localStorage.clear(); } catch { /* ignore */ } }); +afterEach(() => { closeContinuousBurst(); vi.useRealTimers(); harness?.unmount(); harness = null; }); + +const Probe: React.FC<{ id: string; Panel: React.FC }> = ({ id, Panel }) => { + const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id].data.props })); + return ; +}; + +describe('CollapsibleSection persistence', () => { + function mount() { + const el = document.createElement('div'); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { root.render(x); }); + return { el, unmount: () => { act(() => root.unmount()); el.remove(); } }; + } + const header = (el: HTMLElement) => el.querySelector('button') as HTMLButtonElement; + + test('defaults open, and a collapse survives unmount/remount', () => { + let m = mount(); + expect(header(m.el).getAttribute('aria-expanded')).toBe('true'); + act(() => { header(m.el).click(); }); + expect(m.el.querySelector('[data-testid=body]')).toBeNull(); + m.unmount(); + m = mount(); + expect(header(m.el).getAttribute('aria-expanded')).toBe('false'); + expect(m.el.querySelector('[data-testid=body]')).toBeNull(); + m.unmount(); + }); + + test('falls back to defaultOpen when localStorage throws', () => { + const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); }); + const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); }); + try { + const m = mount(); + expect(header(m.el).getAttribute('aria-expanded')).toBe('true'); + expect(() => act(() => { header(m.el).click(); })).not.toThrow(); + expect(header(m.el).getAttribute('aria-expanded')).toBe('false'); + m.unmount(); + } finally { spy.mockRestore(); set.mockRestore(); } + }); +}); + +describe('expandPaddingShorthand', () => { + test.each([ + ['20px', { top: '20px', right: '20px', bottom: '20px', left: '20px' }], + ['40px 0', { top: '40px', right: '0', bottom: '40px', left: '0' }], + ['1px 2px 3px', { top: '1px', right: '2px', bottom: '3px', left: '2px' }], + ['1px 2px 3px 4px', { top: '1px', right: '2px', bottom: '3px', left: '4px' }], + ['calc(1px + 2px) 0', { top: 'calc(1px + 2px)', right: '0', bottom: 'calc(1px + 2px)', left: '0' }], + ['', {}], + ['1 2 3 4 5', {}], + ])('%s', (input, expected) => { expect(expandPaddingShorthand(input)).toEqual(expected); }); +}); + +describe('Container padding is a single control', () => { + const paddingGrid = () => harness!.container.querySelectorAll('[data-testid=spacing-control]'); + + test('Section: M preset sets four longhands, clears shorthand, one undo step', () => { + harness = renderEditorHarness({ initialState: STATE }); + harness.mountChild(); + // no separate shorthand "Padding" preset row: only Margin + Padding controls + expect(Array.from(harness.container.querySelectorAll('.guided-section')).filter((e) => e.textContent?.startsWith('Padding') && !e.getAttribute('data-testid')).length).toBe(0); + expect(paddingGrid().length).toBe(2); + // legacy '40px 0' is shown expanded in the unlinked fields + const m = Array.from(paddingGrid()[1].querySelectorAll('button')).find((b) => b.textContent === 'M')!; + harness.act(() => { harness!.actions.history.clear(); }); + expect(harness.query.history.canUndo()).toBe(false); + harness.act(() => { m.click(); }); + const style = JSON.parse(harness.getSerialized()).sec.props.style; + expect([style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft]).toEqual(['16px', '16px', '16px', '16px']); + expect(style.padding).toBe(''); + expect(harness.query.history.canUndo()).toBe(true); + harness.act(() => { harness!.actions.history.undo(); }); + const back = JSON.parse(harness.getSerialized()).sec.props.style; + expect(back.padding).toBe('40px 0'); + expect(back.paddingTop).toBe(''); + expect(harness.query.history.canUndo()).toBe(false); + }); + + test('editing one side of a legacy shorthand seeds the others, one step', () => { + harness = renderEditorHarness({ initialState: STATE }); + harness.mountChild(); + harness.act(() => { harness!.actions.history.clear(); }); + const pad = paddingGrid()[1]; + harness.act(() => { (pad.querySelector('[data-testid=spacing-link-toggle]') as HTMLButtonElement).click(); }); + const top = pad.querySelector('[data-testid=spacing-top-number]') as HTMLInputElement; + expect(top.value).toBe('40'); + harness.act(() => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!; + setter.call(top, '20'); + top.dispatchEvent(new Event('input', { bubbles: true })); + }); + const style = JSON.parse(harness.getSerialized()).sec.props.style; + expect([style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft]).toEqual(['20px', '0', '40px', '0']); + expect(style.padding).toBe(''); + harness.act(() => { harness!.actions.history.undo(); }); + expect(JSON.parse(harness.getSerialized()).sec.props.style.padding).toBe('40px 0'); + expect(harness.query.history.canUndo()).toBe(false); + }); +}); + +describe('default open/closed sections', () => { + function sections(id: string, Panel: React.FC): Record { + harness = renderEditorHarness({ initialState: STATE }); + harness.mountChild(); + const out: Record = {}; + harness.container.querySelectorAll('button[aria-expanded]').forEach((b) => { + out[(b.textContent || '').trim()] = b.getAttribute('aria-expanded') === 'true'; + }); + return out; + } + + test('Text panel', () => { + expect(sections('head', TextStylePanel)).toEqual({ Spacing: true, 'Border & Effects': false, 'Animation & Visibility': false }); + }); + test('Button panel', () => { + expect(sections('btn', ButtonStylePanel)).toEqual({ 'Hover State': false, Spacing: true, 'Border & Effects': false, 'Animation & Visibility': false }); + }); + test('Container panel', () => { + expect(sections('sec', ContainerStylePanel)).toEqual({ Spacing: true, 'Border & Effects': false, 'Animation & Visibility': false }); + }); +}); diff --git a/craft/src/panels/right/styles/containerBoxModel.tsx b/craft/src/panels/right/styles/containerBoxModel.tsx index 0128048..dee8240 100644 --- a/craft/src/panels/right/styles/containerBoxModel.tsx +++ b/craft/src/panels/right/styles/containerBoxModel.tsx @@ -6,6 +6,7 @@ import { CollapsibleSection, SpacingControl, SpacingSide, + SpacingSideValues, BorderControl, BorderValue, buildBorderShorthand, @@ -41,27 +42,78 @@ export function parseBorderShorthand(v: string | undefined): BorderValue { return { width: m[1], style: m[2], color: m[3] }; } +/** Splits a CSS value on top-level whitespace (so `calc(1px + 2px)` stays one token). */ +function splitCssTokens(v: string): string[] { + const out: string[] = []; + let depth = 0; + let cur = ''; + for (const ch of v.trim()) { + if (ch === '(') depth++; + else if (ch === ')') depth = Math.max(0, depth - 1); + if (/\s/.test(ch) && depth === 0) { + if (cur) out.push(cur); + cur = ''; + } else cur += ch; + } + if (cur) out.push(cur); + return out; +} + +/** Expands a 1-4 value `padding` shorthand into per-side values (CSS order: + * top, right, bottom, left). Anything unparseable yields no sides. */ +export function expandPaddingShorthand(value: string | undefined | null): SpacingSideValues { + const t = splitCssTokens(String(value ?? '')); + if (t.length < 1 || t.length > 4) return {}; + const [top, right = top, bottom = top, left = right] = t; + return { top, right, bottom, left }; +} + +const SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left']; +const PADDING_KEYS = SIDES.map((s) => `padding${capitalize(s)}`); +const hasValue = (v: unknown) => v !== undefined && v !== null && v !== ''; + export interface BoxModelSectionProps { style: Record; setPropStyle: (prop: string, value: string) => void; + /** Writes several style keys in ONE undo step. `controlKey` names the + * control for undo-burst merging (e.g. "style.padding*"). */ + setPropStyles: (patch: Record, controlKey: string) => void; } -/** Margin + Padding, per-side, via the shared SpacingControl. */ -export const BoxModelSection: React.FC = ({ style, setPropStyle }) => { - const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) => - setPropStyle(`${kind}${capitalize(side)}`, value); +/** Margin + Padding, per-side, via the shared SpacingControl. Padding is the + * only padding control: a legacy shorthand `style.padding` (with no + * longhands yet) is shown expanded and migrated to the four longhands on the + * first edit, in the same history step. */ +export const BoxModelSection: React.FC = ({ style, setPropStyle, setPropStyles }) => { + const marginSetter = (side: SpacingSide, value: string) => setPropStyle(`margin${capitalize(side)}`, value); + + const legacyShorthand = hasValue(style.padding) && PADDING_KEYS.every((k) => !hasValue(style[k])); + const seeded = legacyShorthand ? expandPaddingShorthand(style.padding) : {}; + const padding: SpacingSideValues = { + top: hasValue(style.paddingTop) ? style.paddingTop : seeded.top, + right: hasValue(style.paddingRight) ? style.paddingRight : seeded.right, + bottom: hasValue(style.paddingBottom) ? style.paddingBottom : seeded.bottom, + left: hasValue(style.paddingLeft) ? style.paddingLeft : seeded.left, + }; + + const paddingPatch = (sides: SpacingSideValues): Record => { + const patch: Record = { padding: '' }; + SIDES.forEach((s) => { patch[`padding${capitalize(s)}`] = sides[s] ?? ''; }); + return patch; + }; return ( - + setPropStyles(paddingPatch({ ...padding, [side]: value }), `style.padding${capitalize(side)}`)} + onChangeAll={(all) => setPropStyles(paddingPatch(all), 'style.padding*')} /> ); diff --git a/craft/src/panels/right/styles/mediaBoxModel.tsx b/craft/src/panels/right/styles/mediaBoxModel.tsx index 8fe4f1e..b69b951 100644 --- a/craft/src/panels/right/styles/mediaBoxModel.tsx +++ b/craft/src/panels/right/styles/mediaBoxModel.tsx @@ -50,7 +50,7 @@ export const BoxModelSection: React.FC = ({ style, setProp setPropStyle(`${kind}${capitalize(side)}`, value); return ( - + ): NavColorField[] } /* ---------- Collapsible section ---------- */ -export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => { - const [open, setOpen] = useState(defaultOpen); +const slugifyTitle = (t: string) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + +/** Open/closed state is remembered per section type (never per node) under + * `section:`; the key defaults to the slugified title. */ +export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; storageKey?: string; children: React.ReactNode }> = ({ title, defaultOpen = true, storageKey, children }) => { + const prefKey = `section:${storageKey ?? slugifyTitle(title)}`; + const [open, setOpen] = useState(() => { + const v = getPref(prefKey, defaultOpen); + return typeof v === 'boolean' ? v : defaultOpen; + }); + const toggle = () => { + const next = !open; + setOpen(next); + setPref(prefKey, next); + }; return (