2026-07-12 15:13:35 -07:00
import React from 'react' ;
2026-07-14 06:44:21 -07:00
import { useEditor } from '@craftjs/core' ;
2026-04-05 18:31:16 -07:00
import {
BG_COLORS ,
SPACING_PRESETS ,
RADIUS_PRESETS ,
2026-07-14 06:44:21 -07:00
SHADOW_PRESETS ,
2026-04-05 18:31:16 -07:00
} from '../../../constants/presets' ;
import {
StylePanelProps ,
SectionLabel ,
ColorSwatchGrid ,
PresetButtonGrid ,
CollapsibleSection ,
2026-07-14 06:44:21 -07:00
ArrayPropEditor ,
SpacingControl ,
BorderControl ,
BorderValue ,
AnimationControl ,
VisibilityControl ,
buildBorderShorthand ,
2026-04-05 18:31:16 -07:00
labelStyle ,
inputStyle ,
2026-07-14 06:44:21 -07:00
smallInputStyle ,
2026-04-05 18:31:16 -07:00
btnActiveStyle ,
sectionGap ,
2026-07-12 15:13:35 -07:00
useNodeProp ,
2026-04-05 18:31:16 -07:00
} from './shared' ;
2026-07-14 06:44:21 -07:00
/* 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' },
];
2026-04-05 18:31:16 -07:00
/* ---------- FORM ---------- */
export const FormStylePanel : React.FC < StylePanelProps > = ({ selectedId , nodeProps }) => {
2026-07-14 06:44:21 -07:00
const { actions } = useEditor ();
2026-07-12 15:13:35 -07:00
const { setProp , setPropStyle } = useNodeProp ( selectedId );
2026-04-05 18:31:16 -07:00
const style = nodeProps . style || {};
2026-07-14 06:44:21 -07:00
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 ;
});
};
2026-04-05 18:31:16 -07:00
return (
<>
2026-07-14 06:44:21 -07:00
{ /* 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 >
)}
2026-07-07 14:06:24 -07:00
{ /* Contact-form relay: where submissions are emailed. Present on ContactForm
and FormContainer (both have recipientEmail/thankYouUrl props). */ }
{ nodeProps . recipientEmail !== undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Send submissions to ( email )</ label >
< input type = "email" value = { nodeProps . recipientEmail || '' } onChange = {( e ) => setProp ( 'recipientEmail' , e . target . value )} placeholder = "you@example.com" style = { inputStyle } />
< p style = {{ fontSize : 10 , color : '#71717a' , margin : '4px 0 0' }}>
Emailed via the site 's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead.
</p>
</div>
)}
{nodeProps.thankYouUrl !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Thank-you page URL (optional)</label>
<input type="text" value={nodeProps.thankYouUrl || ''} onChange={(e) => setProp(' thankYouUrl ', e.target.value)} placeholder="/thank-you (blank = hosted page)" style={inputStyle} />
</div>
)}
2026-07-14 06:44:21 -07:00
{/* 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 && (
2026-04-05 18:31:16 -07:00
< 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 >
)}
2026-07-14 06:44:21 -07:00
{ /* 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 >
)}
2026-04-05 18:31:16 -07:00
{ nodeProps . method !== undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Method </ label >
< div style = {{ display : 'flex' , gap : 4 }}>
{[ 'GET' , 'POST' ]. map (( m ) => (
< button key = { m } onClick = {() => setProp ( 'method' , m )} style = { btnActiveStyle ( nodeProps . method === m )}>{ m }</ button >
))}
</ div >
</ div >
)}
{ /* Input field props */ }
{ nodeProps . label !== undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Label </ label >
< input type = "text" value = { nodeProps . label || '' } onChange = {( e ) => setProp ( 'label' , e . target . value )} style = { inputStyle } />
</ div >
)}
{ nodeProps . placeholder !== undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Placeholder </ label >
< input type = "text" value = { nodeProps . placeholder || '' } onChange = {( e ) => setProp ( 'placeholder' , e . target . value )} style = { inputStyle } />
</ div >
)}
{ nodeProps . name !== undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Field Name </ label >
< input type = "text" value = { nodeProps . name || '' } onChange = {( e ) => setProp ( 'name' , e . target . value )} style = { inputStyle } />
</ div >
)}
{ nodeProps . type !== undefined && typeof nodeProps . type === 'string' && (
< div style = { sectionGap }>
< label style = { labelStyle }> Input Type </ label >
< select value = { nodeProps . type } onChange = {( e ) => setProp ( 'type' , e . target . value )} style = {{ ... inputStyle , cursor : 'pointer' }}>
{[ 'text' , 'email' , 'password' , 'number' , 'tel' , 'url' ]. map (( t ) => (
< option key = { t } value = { t }>{ t }</ option >
))}
</ select >
</ div >
)}
{ nodeProps . required !== undefined && (
< div style = { sectionGap }>
< label style = {{ ... labelStyle , display : 'flex' , alignItems : 'center' , gap : 6 , cursor : 'pointer' }}>
< input type = "checkbox" checked = { nodeProps . required || false } onChange = {( e ) => setProp ( 'required' , e . target . checked )} />
Required
</ label >
</ div >
)}
{ /* Button text */ }
{ nodeProps . text !== undefined && nodeProps . label === undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Button Text </ label >
< input type = "text" value = { nodeProps . text || '' } onChange = {( e ) => setProp ( 'text' , e . target . value )} style = { inputStyle } />
</ div >
)}
{ /* Subscribe form props */ }
{ nodeProps . buttonText !== undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Button Text </ label >
< input type = "text" value = { nodeProps . buttonText || '' } onChange = {( e ) => setProp ( 'buttonText' , e . target . value )} style = { inputStyle } />
</ div >
)}
{ nodeProps . placeholderText !== undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Placeholder Text </ label >
< input type = "text" value = { nodeProps . placeholderText || '' } onChange = {( e ) => setProp ( 'placeholderText' , e . target . value )} style = { inputStyle } />
</ div >
)}
{ nodeProps . successMessage !== undefined && (
< div style = { sectionGap }>
< label style = { labelStyle }> Success Message </ label >
< input type = "text" value = { nodeProps . successMessage || '' } onChange = {( e ) => setProp ( 'successMessage' , e . target . value )} style = { inputStyle } />
</ div >
)}
{ /* Style */ }
< CollapsibleSection title = "Style" defaultOpen = { false }>
< div className = "guided-section" >
< SectionLabel > Background </ SectionLabel >
< ColorSwatchGrid colors = { BG_COLORS } activeValue = { style . backgroundColor } onSelect = {( v : string ) => setPropStyle ( 'backgroundColor' , 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 presets = { RADIUS_PRESETS } activeValue = { style . borderRadius as string } onSelect = {( v ) => setPropStyle ( 'borderRadius' , v )} />
</ div >
</ CollapsibleSection >
2026-07-14 06:44:21 -07:00
{ /* 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 >
)}
2026-04-05 18:31:16 -07:00
</>
);
};