@@ -8,6 +8,25 @@ interface Props {
88 onChange : ( updated : FormField ) => void ;
99}
1010
11+ // Which settings apply to which field type (drives conditional visibility).
12+ const PLACEHOLDER_TYPES = [ 'text' , 'email' , 'number' , 'phone' , 'url' , 'password' , 'textarea' , 'select' ] ;
13+
14+ const RULE_LABELS : Record < string , string > = {
15+ minLength : 'Min. length' , maxLength : 'Max. length' ,
16+ min : 'Min. value' , max : 'Max. value' ,
17+ email : 'Email' , url : 'URL' , pattern : 'Pattern (regex)' ,
18+ } ;
19+
20+ // Extra validation rules (beyond Required) that make sense for a given type.
21+ // email/url/number already auto-validate their format by type, so those rules
22+ // are only offered where you'd validate free text.
23+ function ruleTypesFor ( type : string ) : string [ ] {
24+ if ( type === 'number' ) return [ 'min' , 'max' ] ;
25+ if ( type === 'text' || type === 'textarea' ) return [ 'minLength' , 'maxLength' , 'pattern' , 'email' , 'url' ] ;
26+ if ( [ 'email' , 'url' , 'phone' , 'password' ] . includes ( type ) ) return [ 'minLength' , 'maxLength' , 'pattern' ] ;
27+ return [ ] ; // select, radio, checkbox, checkbox-group, date, time → only Required
28+ }
29+
1130// One-time style block for input focus + placeholder (inline styles can't do :focus).
1231const STYLE_ID = 'sfb-settings-style' ;
1332function ensureStyle ( ) {
@@ -89,19 +108,28 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
89108 update ( { options : ( field . options || [ ] ) . map ( ( o , j ) => ( j === i ? { ...o , ...patch } : o ) ) } ) ;
90109 const removeOption = ( i : number ) => update ( { options : ( field . options || [ ] ) . filter ( ( _ , j ) => j !== i ) } ) ;
91110
111+ const ruleTypes = ruleTypesFor ( field . type ) ;
92112 const addValidation = ( ) => {
93113 const used = field . validation . map ( ( r ) => r . type ) ;
94- const def =
95- field . type === 'number' ? ( used . includes ( 'min' ) ? ( used . includes ( 'max' ) ? 'pattern' : 'max' ) : 'min' ) :
96- field . type === 'email' ? ( used . includes ( 'email' ) ? 'minLength' : 'email' ) :
97- field . type === 'url' ? ( used . includes ( 'url' ) ? 'minLength' : 'url' ) :
98- used . includes ( 'minLength' ) ? ( used . includes ( 'maxLength' ) ? 'pattern' : 'maxLength' ) : 'minLength' ;
99- update ( { validation : [ ...field . validation , { type : def } ] } ) ;
114+ const next = ruleTypes . find ( ( t ) => ! used . includes ( t ) ) ;
115+ if ( ! next ) return ;
116+ update ( { validation : [ ...field . validation , { type : next } ] } ) ;
100117 } ;
101118 const updateValidation = ( i : number , patch : Partial < ValidationRule > ) =>
102119 update ( { validation : field . validation . map ( ( v , j ) => ( j === i ? { ...v , ...patch } : v ) ) } ) ;
103120 const removeValidation = ( i : number ) => update ( { validation : field . validation . filter ( ( _ , j ) => j !== i ) } ) ;
104121
122+ // "required" is a boolean flag; its custom message lives in a validation rule of type 'required'
123+ const reqMsg = field . validation . find ( ( v ) => v . type === 'required' ) ?. message || '' ;
124+ const setRequired = ( on : boolean ) => {
125+ const rest = field . validation . filter ( ( v ) => v . type !== 'required' ) ;
126+ update ( { required : on , validation : on ? field . validation : rest } ) ;
127+ } ;
128+ const setReqMsg = ( msg : string ) => {
129+ const rest = field . validation . filter ( ( v ) => v . type !== 'required' ) ;
130+ update ( { validation : msg ? [ ...rest , { type : 'required' , message : msg } ] : rest } ) ;
131+ } ;
132+
105133 const kicker = `${ typeName ( field . type ) } field` . toUpperCase ( ) ;
106134
107135 const tabBtn = ( id : 'general' | 'validation' , label : string ) => (
@@ -133,7 +161,13 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
133161 { ! deco && (
134162 < >
135163 < TextField label = "Name (technical)" value = { field . name } onChange = { ( v ) => update ( { name : v } ) } mono hint = "Used as the key in submissions & the API payload." />
136- < TextField label = "Placeholder" value = { field . placeholder || '' } onChange = { ( v ) => update ( { placeholder : v } ) } />
164+ { PLACEHOLDER_TYPES . includes ( field . type ) && (
165+ < TextField
166+ label = { field . type === 'select' ? 'Empty option text' : 'Placeholder' }
167+ value = { field . placeholder || '' }
168+ onChange = { ( v ) => update ( { placeholder : v } ) }
169+ />
170+ ) }
137171 < TextField label = "Help text" value = { field . helpText || '' } onChange = { ( v ) => update ( { helpText : v } ) } />
138172 </ >
139173 ) }
@@ -154,16 +188,6 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
154188 </ Labeled >
155189 ) }
156190
157- { ! deco && (
158- < >
159- < div style = { { height : 1 , background : C . n150 , margin : '2px 0' } } />
160- < div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , gap : 12 } } >
161- < span style = { { font : `600 13px ${ FF } ` , color : C . n700 } } > Required</ span >
162- < Toggle on = { field . required } onClick = { ( ) => update ( { required : ! field . required } ) } />
163- </ div >
164- </ >
165- ) }
166-
167191 { field . type !== 'divider' && (
168192 < Labeled label = "Width" >
169193 < Seg value = { field . width } onChange = { ( v ) => update ( { width : v } ) } />
@@ -176,49 +200,57 @@ export function FieldSettingsPanel({ field, onChange }: Props) {
176200
177201 { tab === 'validation' && ! deco && (
178202 < div style = { { padding : 18 , display : 'flex' , flexDirection : 'column' , gap : 12 } } >
179- < div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' } } >
180- < span style = { { font : `700 11px ${ FF } ` , letterSpacing : '.5px' , textTransform : 'uppercase' , color : C . n500 } } > Rules</ span >
181- < button type = "button" onClick = { addValidation } style = { { height : 30 , padding : '0 10px' , borderRadius : 4 , border : `1px solid ${ C . p200 } ` , background : C . p100 , color : C . p700 , font : `600 12px ${ FF } ` , cursor : 'pointer' } } > + Add rule</ button >
203+ { /* Required — flag + custom message */ }
204+ < div style = { { border : `1px solid ${ C . n200 } ` , borderRadius : 5 , padding : 11 , display : 'flex' , flexDirection : 'column' , gap : 8 , background : C . n0 } } >
205+ < div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' } } >
206+ < span style = { { font : `600 13px ${ FF } ` , color : C . n800 } } > Required</ span >
207+ < Toggle on = { field . required } onClick = { ( ) => setRequired ( ! field . required ) } />
208+ </ div >
209+ { field . required && (
210+ < input className = "sfb-inp" value = { reqMsg } placeholder = { `${ field . label || 'This field' } is required` } onChange = { ( e ) => setReqMsg ( e . target . value ) } style = { inpStyle } />
211+ ) }
182212 </ div >
183213
184- { field . validation . length === 0 && (
185- < span style = { { font : `400 12px ${ FF } ` , color : C . n500 , lineHeight : 1.5 } } > No rules yet. Required is set on the General tab; add length, value or pattern rules here.</ span >
186- ) }
187-
188- { field . validation . map ( ( rule , i ) => {
189- const used = field . validation . filter ( ( _ , j ) => j !== i ) . map ( ( r ) => r . type ) ;
190- const isNumber = field . type === 'number' , isEmail = field . type === 'email' , isUrl = field . type === 'url' ;
191- const available = [
192- ! isNumber && { value : 'minLength' , label : 'Min. length' } ,
193- ! isNumber && { value : 'maxLength' , label : 'Max. length' } ,
194- isNumber && { value : 'min' , label : 'Min. value' } ,
195- isNumber && { value : 'max' , label : 'Max. value' } ,
196- ! isEmail && { value : 'email' , label : 'Email' } ,
197- ! isUrl && { value : 'url' , label : 'URL' } ,
198- { value : 'pattern' , label : 'Pattern (regex)' } ,
199- ] . filter ( ( o ) : o is { value : string ; label : string } => ! ! o && ( o . value === rule . type || ! used . includes ( o . value ) ) ) ;
200- const hasValue = [ 'minLength' , 'maxLength' , 'min' , 'max' , 'pattern' ] . includes ( rule . type ) ;
201- return (
202- < div key = { i } style = { { border : `1px solid ${ C . n200 } ` , borderRadius : 5 , padding : 11 , display : 'flex' , flexDirection : 'column' , gap : 8 , background : C . n0 } } >
203- < div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
204- < select className = "sfb-inp" value = { rule . type } onChange = { ( e ) => updateValidation ( i , { type : e . target . value , value : undefined , message : '' } ) } style = { { ...inpStyle , flex : 1 , cursor : 'pointer' } } >
205- { available . map ( ( o ) => < option key = { o . value } value = { o . value } > { o . label } </ option > ) }
206- </ select >
207- < button type = "button" onClick = { ( ) => removeValidation ( i ) } style = { { width : 28 , height : 28 , borderRadius : 4 , border : `1px solid ${ C . n200 } ` , color : C . n400 , background : C . n0 , cursor : 'pointer' , flex : 'none' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' } } >
208- < Trash width = "14px" height = "14px" fill = "currentColor" />
209- </ button >
210- </ div >
211- { hasValue && (
212- < input className = "sfb-inp" placeholder = { rule . type === 'pattern' ? '^[a-z0-9]+$' : 'Value' } value = { String ( rule . value ?? '' ) } onChange = { ( e ) => updateValidation ( i , { value : e . target . value } ) } style = { { ...inpStyle , ...( rule . type === 'pattern' ? { fontFamily : 'ui-monospace, Menlo, monospace' , fontSize : 12 } : { } ) } } />
213- ) }
214- < input className = "sfb-inp" placeholder = "Custom error message (optional)" value = { rule . message || '' } onChange = { ( e ) => updateValidation ( i , { message : e . target . value } ) } style = { inpStyle } />
214+ { ruleTypes . length > 0 && (
215+ < >
216+ < div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' } } >
217+ < span style = { { font : `700 11px ${ FF } ` , letterSpacing : '.5px' , textTransform : 'uppercase' , color : C . n500 } } > Rules</ span >
218+ < button type = "button" onClick = { addValidation } style = { { height : 30 , padding : '0 10px' , borderRadius : 4 , border : `1px solid ${ C . p200 } ` , background : C . p100 , color : C . p700 , font : `600 12px ${ FF } ` , cursor : 'pointer' } } > + Add rule</ button >
215219 </ div >
216- ) ;
217- } ) }
218220
219- < div style = { { display : 'flex' , alignItems : 'flex-start' , gap : 7 , font : `400 11px ${ FF } ` , color : C . n500 , lineHeight : 1.5 } } >
220- < span style = { { color : C . wrn600 } } > ⚠</ span > Rules run on the public form and in the preview.
221- </ div >
221+ { field . validation . filter ( ( v ) => v . type !== 'required' ) . length === 0 && (
222+ < span style = { { font : `400 12px ${ FF } ` , color : C . n500 , lineHeight : 1.5 } } > No extra rules yet.</ span >
223+ ) }
224+
225+ { field . validation . map ( ( rule , i ) => {
226+ if ( rule . type === 'required' ) return null ;
227+ const used = field . validation . filter ( ( _ , j ) => j !== i ) . map ( ( r ) => r . type ) ;
228+ // options = rules valid for this type, minus ones already used (keep current)
229+ const available = ruleTypes . filter ( ( t ) => t === rule . type || ! used . includes ( t ) ) ;
230+ const hasValue = [ 'minLength' , 'maxLength' , 'min' , 'max' , 'pattern' ] . includes ( rule . type ) ;
231+ return (
232+ < div key = { i } style = { { border : `1px solid ${ C . n200 } ` , borderRadius : 5 , padding : 11 , display : 'flex' , flexDirection : 'column' , gap : 8 , background : C . n0 } } >
233+ < div style = { { display : 'flex' , alignItems : 'center' , gap : 8 } } >
234+ < select className = "sfb-inp" value = { rule . type } onChange = { ( e ) => updateValidation ( i , { type : e . target . value , value : undefined , message : '' } ) } style = { { ...inpStyle , flex : 1 , cursor : 'pointer' } } >
235+ { available . map ( ( t ) => < option key = { t } value = { t } > { RULE_LABELS [ t ] } </ option > ) }
236+ </ select >
237+ < button type = "button" onClick = { ( ) => removeValidation ( i ) } style = { { width : 28 , height : 28 , borderRadius : 4 , border : `1px solid ${ C . n200 } ` , color : C . n400 , background : C . n0 , cursor : 'pointer' , flex : 'none' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' } } >
238+ < Trash width = "14px" height = "14px" fill = "currentColor" />
239+ </ button >
240+ </ div >
241+ { hasValue && (
242+ < input className = "sfb-inp" placeholder = { rule . type === 'pattern' ? '^[a-z0-9]+$' : 'Value' } value = { String ( rule . value ?? '' ) } onChange = { ( e ) => updateValidation ( i , { value : e . target . value } ) } style = { { ...inpStyle , ...( rule . type === 'pattern' ? { fontFamily : 'ui-monospace, Menlo, monospace' , fontSize : 12 } : { } ) } } />
243+ ) }
244+ < input className = "sfb-inp" placeholder = "Custom error message (optional)" value = { rule . message || '' } onChange = { ( e ) => updateValidation ( i , { message : e . target . value } ) } style = { inpStyle } />
245+ </ div >
246+ ) ;
247+ } ) }
248+
249+ < div style = { { display : 'flex' , alignItems : 'flex-start' , gap : 7 , font : `400 11px ${ FF } ` , color : C . n500 , lineHeight : 1.5 } } >
250+ < span style = { { color : C . wrn600 } } > ⚠</ span > Rules run on the public form and in the preview.
251+ </ div >
252+ </ >
253+ ) }
222254 </ div >
223255 ) }
224256 </ div >
0 commit comments