@seed-ship/mcp-ui-solid 4.2.2 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +13 -8
  3. package/dist/components/FormFieldRenderer.cjs +132 -52
  4. package/dist/components/FormFieldRenderer.cjs.map +1 -1
  5. package/dist/components/FormFieldRenderer.js +133 -53
  6. package/dist/components/FormFieldRenderer.js.map +1 -1
  7. package/dist/components/FormRenderer.cjs +25 -6
  8. package/dist/components/FormRenderer.cjs.map +1 -1
  9. package/dist/components/FormRenderer.d.ts.map +1 -1
  10. package/dist/components/FormRenderer.js +25 -6
  11. package/dist/components/FormRenderer.js.map +1 -1
  12. package/dist/components/ScratchpadPanel.cjs +583 -280
  13. package/dist/components/ScratchpadPanel.cjs.map +1 -1
  14. package/dist/components/ScratchpadPanel.d.ts +2 -0
  15. package/dist/components/ScratchpadPanel.d.ts.map +1 -1
  16. package/dist/components/ScratchpadPanel.js +583 -280
  17. package/dist/components/ScratchpadPanel.js.map +1 -1
  18. package/dist/services/validation.cjs +8 -0
  19. package/dist/services/validation.cjs.map +1 -1
  20. package/dist/services/validation.d.ts.map +1 -1
  21. package/dist/services/validation.js +8 -0
  22. package/dist/services/validation.js.map +1 -1
  23. package/dist/types/index.d.ts +8 -0
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/types.d.cts +8 -0
  26. package/dist/types.d.ts +8 -0
  27. package/package.json +1 -1
  28. package/src/components/FormFieldRenderer.tsx +89 -2
  29. package/src/components/FormRenderer.tsx +12 -0
  30. package/src/components/ScratchpadPanel.tsx +174 -1
  31. package/src/services/validation.ts +10 -0
  32. package/src/types/index.ts +8 -0
  33. package/tsconfig.tsbuildinfo +1 -1
package/dist/types.d.ts CHANGED
@@ -275,6 +275,14 @@ export interface FormFieldParams {
275
275
  source?: PrefillSource;
276
276
  /** If true, field is visually muted (but still editable on click/focus). */
277
277
  muted?: boolean;
278
+ /** How to handle prefill for autocomplete fields.
279
+ * - "exact" (default): prefill is the final value (code), use as-is
280
+ * - "resolve": prefill is a display name, call apiUrl to resolve to valueField */
281
+ prefillMode?: 'exact' | 'resolve';
282
+ /** Regex pattern the submitted value MUST match. Prevents submit if invalid. */
283
+ valueFormat?: string;
284
+ /** Human-readable hint shown when valueFormat validation fails. */
285
+ valueFormatHint?: string;
278
286
  minLength?: number;
279
287
  maxLength?: number;
280
288
  pattern?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "4.2.2",
3
+ "version": "4.3.1",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -500,6 +500,7 @@ const AutocompleteField: Component<{
500
500
  const [suggestions, setSuggestions] = createSignal<Array<{ label: string; value: string }>>([])
501
501
  const [isOpen, setIsOpen] = createSignal(false)
502
502
  const [selectedLabels, setSelectedLabels] = createSignal<Map<string, string>>(new Map())
503
+ const [resolving, setResolving] = createSignal(false)
503
504
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
504
505
 
505
506
  const isMultiple = () => props.field.multiple === true
@@ -535,6 +536,72 @@ const AutocompleteField: Component<{
535
536
  }
536
537
  }
537
538
 
539
+ // Proposal 1: prefillMode "resolve" — call apiUrl to resolve display name → code
540
+ const resolvePrefill = async (prefillValues: string[]) => {
541
+ if (!props.field.apiUrl || !props.field.searchParam) return
542
+ setResolving(true)
543
+ const labelField = props.field.labelField || 'label'
544
+ const valueField = props.field.valueField || 'value'
545
+
546
+ try {
547
+ const resolvedValues: string[] = []
548
+ for (const pv of prefillValues) {
549
+ const params = new URLSearchParams({ [props.field.searchParam]: pv, limit: '1' })
550
+ if (props.field.extraParams) {
551
+ for (const [k, v] of Object.entries(props.field.extraParams)) params.set(k, v)
552
+ }
553
+ const res = await fetch(`${props.field.apiUrl}?${params}`)
554
+ if (!res.ok) { resolvedValues.push(pv); continue }
555
+ const data = await res.json()
556
+ const items = Array.isArray(data) ? data : data.results || data.features || []
557
+ if (items.length > 0) {
558
+ const code = String(items[0][valueField] || pv)
559
+ const label = items[0][labelField] || pv
560
+ resolvedValues.push(code)
561
+ setSelectedLabels((prev) => new Map(prev).set(code, label))
562
+ } else {
563
+ resolvedValues.push(pv)
564
+ setSelectedLabels((prev) => new Map(prev).set(pv, pv))
565
+ }
566
+ }
567
+ if (isMultiple()) {
568
+ props.onChange(resolvedValues)
569
+ } else {
570
+ props.onChange(resolvedValues[0] || '')
571
+ const label = selectedLabels().get(resolvedValues[0])
572
+ if (label) setQuery(label)
573
+ }
574
+ } catch {
575
+ // Fallback: use raw prefill values
576
+ } finally {
577
+ setResolving(false)
578
+ }
579
+ }
580
+
581
+ // On mount: handle prefill
582
+ createEffect(() => {
583
+ const prefill = props.field.prefill
584
+ if (!prefill) return
585
+ const values = Array.isArray(prefill) ? prefill : [prefill]
586
+ if (values.length === 0) return
587
+
588
+ if (props.field.prefillMode === 'resolve') {
589
+ // Proposal 1: resolve display names to codes via API
590
+ resolvePrefill(values)
591
+ } else {
592
+ // Proposal 2 + 6: exact mode — prefill is a code, show displayHint or label as tag
593
+ for (const v of values) {
594
+ if (!selectedLabels().has(v)) {
595
+ setSelectedLabels((prev) => new Map(prev).set(v, props.field.displayHint || v))
596
+ }
597
+ }
598
+ if (!isMultiple() && values[0]) {
599
+ const label = props.field.displayHint || values[0]
600
+ setQuery(label)
601
+ }
602
+ }
603
+ })
604
+
538
605
  const handleInput = (value: string) => {
539
606
  setQuery(value)
540
607
  // Only clear the stored value if user is actively changing the text
@@ -567,6 +634,7 @@ const AutocompleteField: Component<{
567
634
  setSuggestions([])
568
635
  setIsOpen(false)
569
636
  } else {
637
+ // Proposal 6: always store valueField (item.value), display labelField
570
638
  props.onChange(item.value)
571
639
  setSelectedLabels((prev) => new Map(prev).set(item.value, item.label))
572
640
  setQuery(item.label)
@@ -575,6 +643,17 @@ const AutocompleteField: Component<{
575
643
  }
576
644
  }
577
645
 
646
+ // Proposal 6: on blur without selection, auto-resolve typed text to first API result
647
+ const handleBlur = () => {
648
+ setTimeout(() => {
649
+ setIsOpen(false)
650
+ // If user typed but didn't select, and field has valueField, resolve first match
651
+ if (!isMultiple() && query() && !props.value && props.field.valueField && suggestions().length > 0) {
652
+ selectSuggestion(suggestions()[0])
653
+ }
654
+ }, 200)
655
+ }
656
+
578
657
  const removeChip = (val: string) => {
579
658
  props.onChange(selectedValues().filter((v) => v !== val))
580
659
  setSelectedLabels((prev) => { const m = new Map(prev); m.delete(val); return m })
@@ -586,6 +665,14 @@ const AutocompleteField: Component<{
586
665
 
587
666
  return (
588
667
  <div class="relative">
668
+ {/* Resolving indicator */}
669
+ <Show when={resolving()}>
670
+ <div class="flex items-center gap-1 mb-1 text-xs text-gray-400">
671
+ <span class="animate-spin h-3 w-3 border border-gray-400 border-t-transparent rounded-full" />
672
+ Resolving...
673
+ </div>
674
+ </Show>
675
+
589
676
  {/* Multi chips */}
590
677
  <Show when={isMultiple() && selectedValues().length > 0}>
591
678
  <div class="flex flex-wrap gap-1 mb-1">
@@ -612,11 +699,11 @@ const AutocompleteField: Component<{
612
699
  value={query()}
613
700
  onInput={(e) => handleInput(e.currentTarget.value)}
614
701
  onFocus={() => { if (suggestions().length) setIsOpen(true) }}
615
- onBlur={() => setTimeout(() => setIsOpen(false), 200)}
702
+ onBlur={handleBlur}
616
703
  placeholder={isMultiple() && selectedValues().length
617
704
  ? 'Add more...'
618
705
  : props.field.placeholder}
619
- disabled={props.disabled}
706
+ disabled={props.disabled || resolving()}
620
707
  class={props.baseClass}
621
708
  autocomplete="off"
622
709
  />
@@ -234,6 +234,18 @@ export const FormRenderer: Component<FormRendererProps> = (props) => {
234
234
  </Show>
235
235
 
236
236
  <form id={`form-${props.component.id}`} onSubmit={handleSubmit} noValidate>
237
+ {/* Proposal 3: prefill summary */}
238
+ <Show when={params().fields.some((f) => f.prefill != null)}>
239
+ {(() => {
240
+ const prefilled = params().fields.filter((f) => f.prefill != null).length
241
+ const total = params().fields.length
242
+ return (
243
+ <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
244
+ {prefilled} champ{prefilled > 1 ? 's' : ''} pré-rempli{prefilled > 1 ? 's' : ''} sur {total}
245
+ </p>
246
+ )
247
+ })()}
248
+ </Show>
237
249
  <div class={layoutClass()}>
238
250
  <For each={params().fields}>
239
251
  {(field) => (
@@ -36,6 +36,8 @@ export interface ScratchpadPanelProps {
36
36
  debug?: boolean
37
37
  /** Show mini debug overlay */
38
38
  debugOverlay?: boolean
39
+ /** Show collapsible debug trace panel under forms */
40
+ debugTrace?: boolean
39
41
  closable?: boolean
40
42
  autoCloseDelay?: number
41
43
  collapsible?: boolean
@@ -243,6 +245,7 @@ export const ScratchpadPanel: Component<ScratchpadPanelProps> = (props) => {
243
245
  onAction={handleAction}
244
246
  onSectionEdit={props.onSectionEdit}
245
247
  onSubmit={props.onSubmit}
248
+ debugTrace={props.debugTrace}
246
249
  />
247
250
  )}
248
251
  </For>
@@ -373,6 +376,7 @@ const SectionRenderer: Component<{
373
376
  onAction?: (action: string, data?: unknown) => void
374
377
  onSectionEdit?: (sectionId: string, content: unknown) => void
375
378
  onSubmit?: (sectionId: string, values: Record<string, unknown>) => void
379
+ debugTrace?: boolean
376
380
  }> = (props) => {
377
381
  return (
378
382
  <div class="px-4 py-3 animate-[slideDown_0.2s_ease-out]">
@@ -383,7 +387,7 @@ const SectionRenderer: Component<{
383
387
  <Match when={props.section.type === 'message'}><p class="text-sm text-gray-700 dark:text-gray-300">{String(props.section.content)}</p></Match>
384
388
  <Match when={props.section.type === 'action'}><ActionSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
385
389
  <Match when={props.section.type === 'steps'}><EnrichedStepsSection content={parseContent(props.section.content)} onAction={props.onAction} onFilterChange={props.onFilterChange} /></Match>
386
- <Match when={props.section.type === 'form'}><EmbeddedFormSection content={parseContent(props.section.content)} sectionId={props.section.id} onAction={props.onAction} onSubmit={props.onSubmit} /></Match>
390
+ <Match when={props.section.type === 'form'}><EmbeddedFormSection content={parseContent(props.section.content)} sectionId={props.section.id} onAction={props.onAction} onSubmit={props.onSubmit} debugTrace={props.debugTrace} /></Match>
387
391
  <Match when={props.section.type === 'understanding'}><UnderstandingSection content={parseContent(props.section.content)} /></Match>
388
392
  <Match when={props.section.type === 'feedback'}><FeedbackSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
389
393
  <Match when={props.section.type === 'prompt'}><PromptSection content={parseContent(props.section.content)} onAction={props.onAction} /></Match>
@@ -524,6 +528,7 @@ const EmbeddedFormSection: Component<{
524
528
  sectionId: string
525
529
  onAction?: (action: string, data?: unknown) => void
526
530
  onSubmit?: (sectionId: string, values: Record<string, unknown>) => void
531
+ debugTrace?: boolean
527
532
  }> = (props) => {
528
533
  const [dynamicOptions, setDynamicOptions] = createSignal<Record<string, Array<{ label: string; value: string }>>>({})
529
534
 
@@ -631,6 +636,9 @@ const EmbeddedFormSection: Component<{
631
636
  return dynOpts ? { ...field, options: dynOpts } as FormFieldParams : field as FormFieldParams
632
637
  }
633
638
 
639
+ // Debug trace: track submitted values
640
+ const [submittedValues, setSubmittedValues] = createSignal<Record<string, any> | null>(null)
641
+
634
642
  const handleSubmit = (e: Event) => {
635
643
  e.preventDefault()
636
644
 
@@ -644,6 +652,8 @@ const EmbeddedFormSection: Component<{
644
652
  .filter(([, v]) => v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0))
645
653
  )
646
654
 
655
+ setSubmittedValues(values)
656
+
647
657
  // DX1 Etape 7: form submit log
648
658
  console.info(`%c[MCP-UI] Form submitted%c section=${props.sectionId} fields=${Object.keys(values).join(',')}`, 'color: #8b5cf6; font-weight: bold', 'color: inherit')
649
659
 
@@ -654,8 +664,43 @@ const EmbeddedFormSection: Component<{
654
664
  }
655
665
  }
656
666
 
667
+ // Proposal 3: prefill confidence summary
668
+ const prefillSummary = () => {
669
+ const fields = config().fields
670
+ const total = fields.length
671
+ const prefilled = fields.filter((f: any) => f.prefill != null).length
672
+ return { total, prefilled }
673
+ }
674
+
675
+ // Proposal 4: auto-submit toast mode — compact view when ALL fields prefilled
676
+ const [expanded, setExpanded] = createSignal(false)
677
+ const allFieldsPrefilled = () => {
678
+ const fields = config().fields
679
+ return fields.length > 0 && fields.every((f: any) => f.prefill != null)
680
+ }
681
+ const showToast = () => allFieldsPrefilled() && !userInteracted() && !expanded() && countdown() != null
682
+
657
683
  return (
684
+ <>
685
+ <Show when={!showToast()} fallback={
686
+ <div class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-sm">
687
+ <span class="flex-1 text-blue-800 dark:text-blue-200 font-medium">
688
+ {config().fields.map((f: any) => f.displayHint || f.prefill).join(', ')}
689
+ </span>
690
+ <span class="text-blue-600 dark:text-blue-300">{countdown()}s...</span>
691
+ <button type="button" onClick={() => { setExpanded(true); cancelCountdown(); setUserInteracted(true) }}
692
+ class="text-blue-600 dark:text-blue-400 underline text-xs">Modifier</button>
693
+ <button type="button" onClick={() => { cancelCountdown(); setUserInteracted(true) }}
694
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">&times;</button>
695
+ </div>
696
+ }>
658
697
  <form id={`scratchpad-form-${props.sectionId}`} onSubmit={handleSubmit} class="flex flex-col gap-3">
698
+ {/* Proposal 3: prefill summary */}
699
+ <Show when={prefillSummary().prefilled > 0}>
700
+ <p class="text-xs text-gray-500 dark:text-gray-400">
701
+ {prefillSummary().prefilled} champ{prefillSummary().prefilled > 1 ? 's' : ''} pré-rempli{prefillSummary().prefilled > 1 ? 's' : ''} sur {prefillSummary().total}
702
+ </p>
703
+ </Show>
659
704
  <For each={config().fields}>
660
705
  {(field) => (
661
706
  <FormFieldRenderer
@@ -686,6 +731,134 @@ const EmbeddedFormSection: Component<{
686
731
  </button>
687
732
  </div>
688
733
  </form>
734
+ </Show>
735
+
736
+ {/* Debug trace panel */}
737
+ <Show when={props.debugTrace}>
738
+ <FormDebugTrace
739
+ fields={config().fields}
740
+ formData={formData()}
741
+ submittedValues={submittedValues()}
742
+ autoSubmitDelay={config().autoSubmitDelay}
743
+ userInteracted={userInteracted()}
744
+ rawContent={props.content}
745
+ />
746
+ </Show>
747
+ </>
748
+ )
749
+ }
750
+
751
+ // ─── Form Debug Trace Panel ─────────────────────────────────
752
+
753
+ const FormDebugTrace: Component<{
754
+ fields: any[]
755
+ formData: Record<string, any>
756
+ submittedValues: Record<string, any> | null
757
+ autoSubmitDelay?: number
758
+ userInteracted: boolean
759
+ rawContent: unknown
760
+ }> = (props) => {
761
+ const [open, setOpen] = createSignal(false)
762
+ const [showRaw, setShowRaw] = createSignal(false)
763
+
764
+ const prefilledCount = () => props.fields.filter((f: any) => f.prefill != null).length
765
+ const requiredFields = () => props.fields.filter((f: any) => f.required)
766
+ const missingRequired = () => requiredFields().filter((f: any) => f.prefill == null)
767
+
768
+ const autoSubmitReason = () => {
769
+ if (!props.autoSubmitDelay) return 'no autoSubmitDelay configured'
770
+ if (missingRequired().length > 0) return `${missingRequired().length} required field(s) without prefill`
771
+ if (props.userInteracted) return 'user interacted — cancelled'
772
+ return 'all conditions met'
773
+ }
774
+
775
+ // Server-side _debug data
776
+ const serverDebug = () => (props.rawContent as any)?._debug
777
+
778
+ return (
779
+ <div class="mt-2 border border-gray-200 dark:border-gray-700 rounded-md text-xs font-mono">
780
+ <button
781
+ type="button"
782
+ onClick={() => setOpen(!open())}
783
+ class="w-full px-3 py-1.5 text-left text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 flex items-center gap-1"
784
+ >
785
+ <span>{open() ? '\u25BE' : '\u25B8'}</span>
786
+ Debug trace ({prefilledCount()}/{props.fields.length} prefilled)
787
+ </button>
788
+ <Show when={open()}>
789
+ <div class="px-3 pb-3 space-y-2 text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2">
790
+ <div>Fields: {props.fields.length} total, {prefilledCount()} prefilled</div>
791
+
792
+ <For each={props.fields}>
793
+ {(field: any) => (
794
+ <div class="pl-2 border-l-2 border-gray-200 dark:border-gray-600 space-y-0.5">
795
+ <div class="text-gray-800 dark:text-gray-200 font-medium">{field.name}:</div>
796
+ <Show when={field.prefill != null}>
797
+ <div> prefill: {JSON.stringify(field.prefill)}</div>
798
+ </Show>
799
+ <Show when={!field.prefill}>
800
+ <div class="text-amber-500"> prefill: (none)</div>
801
+ </Show>
802
+ <Show when={field.source}><div> source: {field.source}</div></Show>
803
+ <Show when={field.displayHint}><div> displayHint: "{field.displayHint}"</div></Show>
804
+ <Show when={field.muted}><div> muted: true</div></Show>
805
+ <Show when={field.prefillMode}><div> prefillMode: {field.prefillMode}</div></Show>
806
+ <Show when={field.valueFormat}><div> valueFormat: /{field.valueFormat}/</div></Show>
807
+ <Show when={props.submittedValues}>
808
+ {(() => {
809
+ const v = props.submittedValues![field.name]
810
+ const hasValue = v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0)
811
+ return (
812
+ <div class={hasValue ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}>
813
+ {'\u2192'} submitted: {hasValue ? `${JSON.stringify(v)} \u2713` : '(empty)'}
814
+ </div>
815
+ )
816
+ })()}
817
+ </Show>
818
+ </div>
819
+ )}
820
+ </For>
821
+
822
+ <div class="pt-1 border-t border-gray-200 dark:border-gray-600">
823
+ autoSubmit: {props.autoSubmitDelay ? 'true' : 'false'}
824
+ {props.autoSubmitDelay ? ` (${props.autoSubmitDelay}ms)` : ''}
825
+ <div> reason: {autoSubmitReason()}</div>
826
+ </div>
827
+
828
+ <Show when={serverDebug()}>
829
+ <div class="pt-1 border-t border-gray-200 dark:border-gray-600">
830
+ <div class="font-medium text-gray-800 dark:text-gray-200">Server _debug:</div>
831
+ <Show when={serverDebug()?.resolvers}>
832
+ <For each={serverDebug().resolvers}>
833
+ {(r: any) => (
834
+ <div class="pl-2">
835
+ {r.field}: {r.resolver} "{r.input}" {'\u2192'} "{r.output}" ({r.ms}ms)
836
+ </div>
837
+ )}
838
+ </For>
839
+ </Show>
840
+ <Show when={serverDebug()?.routing}>
841
+ <div class="pl-2">routing: {serverDebug().routing.topic} via {serverDebug().routing.method} ({serverDebug().routing.ms}ms)</div>
842
+ </Show>
843
+ <Show when={serverDebug()?.missingFields}>
844
+ <div class="pl-2 text-amber-500">missing: {serverDebug().missingFields.join(', ')}</div>
845
+ </Show>
846
+ </div>
847
+ </Show>
848
+
849
+ <div class="pt-1 border-t border-gray-200 dark:border-gray-600">
850
+ <button type="button" onClick={() => setShowRaw(!showRaw())} class="text-blue-500 hover:text-blue-700 underline">
851
+ {showRaw() ? 'Hide' : 'Show'} raw SSE payload
852
+ </button>
853
+ <Show when={showRaw()}>
854
+ <pre class="mt-1 p-2 bg-gray-50 dark:bg-gray-900 rounded max-h-48 overflow-auto text-[10px]">
855
+ {JSON.stringify(props.rawContent, null, 2)}
856
+ </pre>
857
+ </Show>
858
+ </div>
859
+ </div>
860
+ </Show>
861
+ </div>
689
862
  )
690
863
  }
691
864
 
@@ -939,6 +939,16 @@ export function validateFieldValue(
939
939
  break
940
940
  }
941
941
 
942
+ // valueFormat validation (v4.3.0) — runs after type-specific checks
943
+ if (field.valueFormat && value !== undefined && value !== null && value !== '') {
944
+ const vals = Array.isArray(value) ? value : [String(value)]
945
+ for (const v of vals) {
946
+ if (!new RegExp(field.valueFormat).test(v)) {
947
+ return { valid: false, error: field.valueFormatHint || `Invalid format (expected: ${field.valueFormat})` }
948
+ }
949
+ }
950
+ }
951
+
942
952
  return { valid: true }
943
953
  }
944
954
 
@@ -345,6 +345,14 @@ export interface FormFieldParams {
345
345
  source?: PrefillSource
346
346
  /** If true, field is visually muted (but still editable on click/focus). */
347
347
  muted?: boolean
348
+ /** How to handle prefill for autocomplete fields.
349
+ * - "exact" (default): prefill is the final value (code), use as-is
350
+ * - "resolve": prefill is a display name, call apiUrl to resolve to valueField */
351
+ prefillMode?: 'exact' | 'resolve'
352
+ /** Regex pattern the submitted value MUST match. Prevents submit if invalid. */
353
+ valueFormat?: string
354
+ /** Human-readable hint shown when valueFormat validation fails. */
355
+ valueFormatHint?: string
348
356
 
349
357
  // Text/textarea specific
350
358
  minLength?: number