Merge PR #17: enh forms

This commit was merged in pull request #17.
This commit is contained in:
2026-07-14 13:49:57 +00:00
15 changed files with 561 additions and 11 deletions
@@ -1,8 +1,10 @@
import React from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
SPACING_PRESETS,
RADIUS_PRESETS,
SHADOW_PRESETS,
} from '../../../constants/presets';
import {
StylePanelProps,
@@ -10,21 +12,152 @@ import {
ColorSwatchGrid,
PresetButtonGrid,
CollapsibleSection,
ArrayPropEditor,
SpacingControl,
BorderControl,
BorderValue,
AnimationControl,
VisibilityControl,
buildBorderShorthand,
labelStyle,
inputStyle,
smallInputStyle,
btnActiveStyle,
sectionGap,
useNodeProp,
} from './shared';
/* The full sanitizeInputType (utils/escape.ts) allowlist, plus the two
fake "types" (textarea/select) that take their own ContactForm render
branch instead of an <input type>. Kept as a local list (rather than
importing the runtime array from utils/escape.ts) since this is
presentation-only -- the actual security boundary is enforced in
ContactForm.toHtml via sanitizeInputType, not here. */
const CONTACT_FIELD_TYPES = [
'text', 'email', 'tel', 'number', 'password', 'url', 'search', 'date',
'checkbox', 'radio', 'textarea', 'select',
];
const moveBtnStyle: React.CSSProperties = {
flex: 1, padding: '3px 6px', fontSize: 10, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
function parseBorderValue(v: unknown): BorderValue {
const s = typeof v === 'string' ? v.trim() : '';
if (!s || s === 'none') return { width: '', style: 'none', color: '' };
const m = s.match(/^(\S+)\s+(\S+)\s+(.+)$/);
if (!m) return { width: '', style: 'none', color: '' };
return { width: m[1], style: m[2], color: m[3] };
}
const SPACING_SIDE_KEYS: { side: 'top' | 'right' | 'bottom' | 'left'; suffix: 'Top' | 'Right' | 'Bottom' | 'Left' }[] = [
{ side: 'top', suffix: 'Top' },
{ side: 'right', suffix: 'Right' },
{ side: 'bottom', suffix: 'Bottom' },
{ side: 'left', suffix: 'Left' },
];
/* ---------- FORM ---------- */
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const style = nodeProps.style || {};
const updateField = (index: number, patch: Record<string, any>) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.fields || [])];
updated[index] = { ...updated[index], ...patch };
props.fields = updated;
});
};
const moveField = (index: number, direction: -1 | 1) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.fields || [])];
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= updated.length) return;
[updated[index], updated[newIndex]] = [updated[newIndex], updated[index]];
props.fields = updated;
});
};
return (
<>
{/* ContactForm field editor: add/remove/reorder fields, set each
field's label, name, type (full allowlist + textarea/select),
options (select only), and required flag. */}
{nodeProps.fields !== undefined && Array.isArray(nodeProps.fields) && (
<CollapsibleSection title="Fields">
<ArrayPropEditor
selectedId={selectedId}
propKey="fields"
items={nodeProps.fields}
renderItem={(item: any, index: number) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
type="text"
value={item.label || ''}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="Label"
style={smallInputStyle}
/>
<input
type="text"
value={item.name || ''}
onChange={(e) => updateField(index, { name: e.target.value })}
placeholder="Field name (e.g. email)"
style={smallInputStyle}
/>
<input
type="text"
value={item.placeholder || ''}
onChange={(e) => updateField(index, { placeholder: e.target.value })}
placeholder="Placeholder"
style={smallInputStyle}
/>
<select
value={item.type || 'text'}
onChange={(e) => updateField(index, { type: e.target.value })}
style={{ ...smallInputStyle, cursor: 'pointer' }}
>
{CONTACT_FIELD_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
{item.type === 'select' && (
<input
type="text"
value={(item.options || []).join(', ')}
onChange={(e) => updateField(index, {
options: e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean),
})}
placeholder="Options (comma-separated)"
style={smallInputStyle}
/>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!item.required}
onChange={(e) => updateField(index, { required: e.target.checked })}
/>
Required
</label>
<div style={{ display: 'flex', gap: 4 }}>
<button disabled={index === 0} onClick={() => moveField(index, -1)} style={{ ...moveBtnStyle, opacity: index === 0 ? 0.4 : 1 }} title="Move up">
<i className="fa fa-arrow-up" />
</button>
<button disabled={index === nodeProps.fields.length - 1} onClick={() => moveField(index, 1)} style={{ ...moveBtnStyle, opacity: index === nodeProps.fields.length - 1 ? 0.4 : 1 }} title="Move down">
<i className="fa fa-arrow-down" />
</button>
</div>
</div>
)}
emptyItem={{ type: 'text', label: 'New Field', name: 'field', placeholder: '', required: false }}
/>
</CollapsibleSection>
)}
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
and FormContainer (both have recipientEmail/thankYouUrl props). */}
{nodeProps.recipientEmail !== undefined && (
@@ -43,13 +176,35 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
</div>
)}
{/* Form action/method */}
{nodeProps.action !== undefined && (
{/* Form action/method (FormContainer). SearchBar also has an `action`
prop but is distinguished via its unique `showButton` prop -- see
the dedicated Search block below -- so it doesn't get this label. */}
{nodeProps.action !== undefined && nodeProps.showButton === undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Form Action URL</label>
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="https://..." style={inputStyle} />
</div>
)}
{/* SearchBar: where the GET search request is submitted. */}
{nodeProps.showButton !== undefined && nodeProps.action !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Search Results Page</label>
<input type="text" value={nodeProps.action || ''} onChange={(e) => setProp('action', e.target.value)} placeholder="/ (site root) or /search" style={inputStyle} />
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
Submits a GET request with the query as ?q=... to this URL.
</p>
</div>
)}
{nodeProps.showButton !== undefined && (
<div style={sectionGap}>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={nodeProps.showButton !== false} onChange={(e) => setProp('showButton', e.target.checked)} />
Show Search Button
</label>
</div>
)}
{nodeProps.method !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Method</label>
@@ -142,6 +297,64 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setPropStyle('borderRadius', v)} />
</div>
</CollapsibleSection>
{/* 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}>
<SpacingControl
label="Margin"
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={(side, v) => setPropStyle(`margin${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
/>
<SpacingControl
label="Padding (per side)"
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={(side, v) => setPropStyle(`padding${SPACING_SIDE_KEYS.find((s) => s.side === side)!.suffix}`, v)}
/>
<BorderControl
value={parseBorderValue(style.border)}
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
/>
<div className="guided-section">
<SectionLabel>Box Shadow</SectionLabel>
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
</div>
<div className="guided-section">
<SectionLabel>Opacity</SectionLabel>
<input
type="range"
min={0}
max={100}
value={style.opacity !== undefined && style.opacity !== '' ? Math.round(Number(style.opacity) * 100) : 100}
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
style={{ width: '100%' }}
/>
</div>
</CollapsibleSection>
{/* Animation & Visibility -- gated on the blank/false defaults added to
each owned component's craft.props (ContactForm, FormContainer,
InputField, TextareaField, FormButton, SubscribeForm, SearchBar).
No toHtml change needed: the export's buildDataAttrs() already
emits data-animation/data-hide-* from these exact prop names for
every node. */}
{nodeProps.animation !== undefined && (
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
<AnimationControl
value={{ animation: nodeProps.animation, 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>
)}
</>
);
};