+ html: `${marker}
${headingHtml}
-
`,
diff --git a/craft/src/components/forms/TextareaField.toHtml.test.ts b/craft/src/components/forms/TextareaField.toHtml.test.ts
index e5227a1..b819e1a 100644
--- a/craft/src/components/forms/TextareaField.toHtml.test.ts
+++ b/craft/src/components/forms/TextareaField.toHtml.test.ts
@@ -63,3 +63,20 @@ describe('TextareaField.toHtml rows attribute sanitization', () => {
expect(html).toContain('rows="8"');
});
});
+
+describe('TextareaField.toHtml box-model style passthrough', () => {
+ test('margin/border/box-shadow/opacity flow through via the style prop', () => {
+ const { html } = toHtml({ label: 'Message', name: 'message', style: { marginTop: '10px', border: '1px solid #555', boxShadow: '0 1px 3px rgba(0,0,0,.15)', opacity: '0.7' } }, '');
+ expect(html).toContain('margin-top:10px');
+ expect(html).toContain('border:1px solid #555');
+ expect(html).toContain('opacity:0.7');
+ });
+});
+
+describe('TextareaField.craft.props includes animation/visibility defaults', () => {
+ test('has blank/false defaults', () => {
+ expect(TextareaField.craft!.props).toMatchObject({
+ animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false,
+ });
+ });
+});
diff --git a/craft/src/components/forms/TextareaField.tsx b/craft/src/components/forms/TextareaField.tsx
index 526a4a9..aeb6a12 100644
--- a/craft/src/components/forms/TextareaField.tsx
+++ b/craft/src/components/forms/TextareaField.tsx
@@ -10,6 +10,11 @@ interface TextareaFieldProps {
rows?: number;
required?: boolean;
style?: CSSProperties;
+ animation?: string;
+ animationDelay?: string;
+ hideOnDesktop?: boolean;
+ hideOnTablet?: boolean;
+ hideOnMobile?: boolean;
}
export const TextareaField: UserComponent
= ({
@@ -79,6 +84,11 @@ TextareaField.craft = {
rows: 4,
required: false,
style: {},
+ animation: '',
+ animationDelay: '',
+ hideOnDesktop: false,
+ hideOnTablet: false,
+ hideOnMobile: false,
},
rules: {
canDrag: () => true,
diff --git a/craft/src/panels/right/styles/FormStylePanel.tsx b/craft/src/panels/right/styles/FormStylePanel.tsx
index 7e22e64..bafa26c 100644
--- a/craft/src/panels/right/styles/FormStylePanel.tsx
+++ b/craft/src/panels/right/styles/FormStylePanel.tsx
@@ -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 . 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 = ({ selectedId, nodeProps }) => {
+ const { actions } = useEditor();
const { setProp, setPropStyle } = useNodeProp(selectedId);
const style = nodeProps.style || {};
+ const updateField = (index: number, patch: Record) => {
+ 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) && (
+
+ (
+
+
updateField(index, { label: e.target.value })}
+ placeholder="Label"
+ style={smallInputStyle}
+ />
+
updateField(index, { name: e.target.value })}
+ placeholder="Field name (e.g. email)"
+ style={smallInputStyle}
+ />
+
updateField(index, { placeholder: e.target.value })}
+ placeholder="Placeholder"
+ style={smallInputStyle}
+ />
+
updateField(index, { type: e.target.value })}
+ style={{ ...smallInputStyle, cursor: 'pointer' }}
+ >
+ {CONTACT_FIELD_TYPES.map((t) => {t} )}
+
+ {item.type === 'select' && (
+
updateField(index, {
+ options: e.target.value.split(',').map((s: string) => s.trim()).filter(Boolean),
+ })}
+ placeholder="Options (comma-separated)"
+ style={smallInputStyle}
+ />
+ )}
+
+ updateField(index, { required: e.target.checked })}
+ />
+ Required
+
+
+ moveField(index, -1)} style={{ ...moveBtnStyle, opacity: index === 0 ? 0.4 : 1 }} title="Move up">
+
+
+ moveField(index, 1)} style={{ ...moveBtnStyle, opacity: index === nodeProps.fields.length - 1 ? 0.4 : 1 }} title="Move down">
+
+
+
+
+ )}
+ emptyItem={{ type: 'text', label: 'New Field', name: 'field', placeholder: '', required: false }}
+ />
+
+ )}
+
{/* 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 = ({ selectedId, nodeProp
)}
- {/* 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 && (
+
Search Results Page
+
setProp('action', e.target.value)} placeholder="/ (site root) or /search" style={inputStyle} />
+
+ Submits a GET request with the query as ?q=... to this URL.
+
+
+ )}
+ {nodeProps.showButton !== undefined && (
+