Merge issue-35 (Refs #35)

This commit is contained in:
shadowdao committed 2026-10-07 11:30:16 -07:00
commit bee052833c
17 files changed
+263 -44

No files matched your search

@@ -47,7 +47,7 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
</CollapsibleSection>
{/* Layout */}
<CollapsibleSection title="Layout" defaultOpen={false}>
<CollapsibleSection title="Layout">
<div style={sectionGap}>
<label style={labelStyle}>Inner Max Width</label>
<input type="text" value={nodeProps.innerMaxWidth || '1200px'}
@@ -146,7 +146,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
</CollapsibleSection>
{/* Box model + border/effects + animation/visibility rollout */}
<CollapsibleSection title="Spacing" defaultOpen={false}>
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
@@ -1,7 +1,7 @@
import React, { CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS,
} from '../../../constants/presets';
import {
@@ -15,6 +15,7 @@ import {
inputStyle,
sectionGap,
useNodeProp,
continuousSetProp,
} from './shared';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './containerBoxModel';
@@ -33,6 +34,7 @@ const VERTICAL_ALIGN_OPTIONS: { label: string; value: string }[] = [
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ 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<StylePanelProps> = ({ 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<string, string>, 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<StylePanelProps> = ({ selectedId, nod
onSelect={(v) => setPropStyle('background', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<PresetButtonGrid
presets={SPACING_PRESETS}
activeValue={style.padding as string}
onSelect={(v) => setPropStyle('padding', v)}
/>
</div>
<div className="guided-section">
<SectionLabel>Border Radius</SectionLabel>
<PresetButtonGrid
@@ -137,7 +134,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
)}
{/* Box model + border/effects + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
@@ -478,7 +478,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
)}
{/* Style */}
<CollapsibleSection title="Style" defaultOpen={false}>
<CollapsibleSection title="Style">
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
@@ -496,7 +496,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ 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. */}
<CollapsibleSection title="Spacing, Border & Effects" defaultOpen={false}>
<CollapsibleSection title="Spacing, Border & Effects">
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
@@ -263,7 +263,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
)}
{/* Background & radius */}
<CollapsibleSection title="Style" defaultOpen={false}>
<CollapsibleSection title="Style">
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
@@ -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(<MediaStylePanel selectedId="map-1" nodeProps={lastProps} />);
@@ -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', () => {
@@ -405,7 +405,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ 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). */}
<CollapsibleSection title="Spacing" defaultOpen={false}>
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
@@ -227,7 +227,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
</CollapsibleSection>
{/* Colors */}
<CollapsibleSection title="Colors" defaultOpen={false}>
<CollapsibleSection title="Colors">
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => 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<StylePanelProps> = ({ selectedId, nodeP
</CollapsibleSection>
{/* Box model: margin, padding, border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
<CollapsibleSection title="Spacing & Border">
<SpacingControl
label="Margin"
value={{
@@ -129,7 +129,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
})}
{/* Style */}
<CollapsibleSection title="Style" defaultOpen={false}>
<CollapsibleSection title="Style">
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
@@ -155,7 +155,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
</CollapsibleSection>
{/* Box model: margin, padding, border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
<CollapsibleSection title="Spacing & Border">
<SpacingControl
label="Margin"
value={{
@@ -247,7 +247,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
)}
{/* Background & padding */}
<CollapsibleSection title="Style" defaultOpen={false}>
<CollapsibleSection title="Style">
<div className="guided-section">
<SectionLabel>Background</SectionLabel>
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
@@ -259,7 +259,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
</CollapsibleSection>
{/* Box model: margin, padding (per-side), border, shadow, opacity */}
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
<CollapsibleSection title="Spacing & Border">
<SpacingControl
label="Margin"
value={{
@@ -175,7 +175,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
</div>
{/* Box model + border/effects + animation/visibility rollout */}
<CollapsibleSection title="Spacing" defaultOpen={false}>
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
@@ -0,0 +1,149 @@
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { useEditor } from '@craftjs/core';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import { CollapsibleSection, closeContinuousBurst } from './shared';
import { ContainerStylePanel } from './ContainerStylePanel';
import { TextStylePanel } from './TextStylePanel';
import { ButtonStylePanel } from './ButtonStylePanel';
import { expandPaddingShorthand } from './containerBoxModel';
const node = (type: string, props: any, extra: any = {}) => ({
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<any> }> = ({ id, Panel }) => {
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id].data.props }));
return <Panel selectedId={id} nodeProps={nodeProps} />;
};
describe('CollapsibleSection persistence', () => {
function mount() {
const el = document.createElement('div');
document.body.appendChild(el);
const root = createRoot(el);
act(() => { root.render(<CollapsibleSection title="Spacing"><span data-testid="body">x</span></CollapsibleSection>); });
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(<Probe id="sec" Panel={ContainerStylePanel} />);
// 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(<Probe id="sec" Panel={ContainerStylePanel} />);
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<any>): Record<string, boolean> {
harness = renderEditorHarness({ initialState: STATE });
harness.mountChild(<Probe id={id} Panel={Panel} />);
const out: Record<string, boolean> = {};
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 });
});
});
@@ -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<string, any>;
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<string, string>, controlKey: 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);
/** 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<BoxModelSectionProps> = ({ 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<string, string> => {
const patch: Record<string, string> = { padding: '' };
SIDES.forEach((s) => { patch[`padding${capitalize(s)}`] = sides[s] ?? ''; });
return patch;
};
return (
<CollapsibleSection title="Spacing" defaultOpen={false}>
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
onChange={marginSetter}
/>
<SpacingControl
label="Padding"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
value={padding}
onChange={(side, value) => setPropStyles(paddingPatch({ ...padding, [side]: value }), `style.padding${capitalize(side)}`)}
onChangeAll={(all) => setPropStyles(paddingPatch(all), 'style.padding*')}
/>
</CollapsibleSection>
);
@@ -50,7 +50,7 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
setPropStyle(`${kind}${capitalize(side)}`, value);
return (
<CollapsibleSection title="Spacing" defaultOpen={false}>
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
+25 -5
View File
@@ -8,6 +8,7 @@ import {
BORDER_STYLES,
} from '../../../constants/presets';
import { uploadAsset } from '../../../utils/assets';
import { getPref, setPref } from '../../../utils/local-prefs';
/* ---------- Helper: auto text color for bg ---------- */
export function autoTextColor(bg: string): string {
@@ -387,12 +388,26 @@ export function navColorFields(nodeProps: Record<string, any>): 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:<storageKey>`; 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<boolean>(() => {
const v = getPref<unknown>(prefKey, defaultOpen);
return typeof v === 'boolean' ? v : defaultOpen;
});
const toggle = () => {
const next = !open;
setOpen(next);
setPref(prefKey, next);
};
return (
<div style={{ borderTop: '1px solid #2d2d3a', paddingTop: 8, marginTop: 4 }}>
<button
onClick={() => setOpen(!open)}
onClick={toggle}
aria-expanded={open}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', background: 'none', border: 'none', color: '#a1a1aa', fontSize: 11, fontWeight: 600, cursor: 'pointer', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}
>
<i className={`fa fa-chevron-${open ? 'down' : 'right'}`} style={{ fontSize: 8, width: 10 }} />
@@ -750,13 +765,18 @@ export interface SpacingControlProps {
/** Called per-side. Linked edits/presets call this once for each of the
* 4 sides (in top/right/bottom/left order) with the same value. */
onChange: (side: SpacingSide, value: string) => void;
/** Optional: linked/preset edits call this once with all four sides instead
* of calling `onChange` four times, so a consumer can make them one undo step. */
onChangeAll?: (values: Required<SpacingSideValues>) => void;
presets?: { label: string; value: string }[];
units?: string[];
}
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, onChangeAll, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
const [linked, setLinked] = useState(true);
const applyToAllSides = (v: string) => SPACING_SIDES.forEach((s) => onChange(s, v));
const applyToAllSides = (v: string) => (onChangeAll
? onChangeAll({ top: v, right: v, bottom: v, left: v })
: SPACING_SIDES.forEach((s) => onChange(s, v)));
const linkedValue = value.top ?? '';
const isAllEqual = SPACING_SIDES.every((s) => (value[s] ?? '') === linkedValue);
@@ -37,7 +37,7 @@ const INITIAL_STATE = JSON.stringify({
let harness: EditorHarness | null = null;
beforeEach(() => { vi.useFakeTimers(); });
beforeEach(() => { try { window.localStorage.clear(); } catch { /* ignore */ } vi.useFakeTimers(); });
afterEach(() => {
closeContinuousBurst();
vi.useRealTimers();