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

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.
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.0",
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) => (
@@ -654,8 +654,42 @@ const EmbeddedFormSection: Component<{
654
654
  }
655
655
  }
656
656
 
657
+ // Proposal 3: prefill confidence summary
658
+ const prefillSummary = () => {
659
+ const fields = config().fields
660
+ const total = fields.length
661
+ const prefilled = fields.filter((f: any) => f.prefill != null).length
662
+ return { total, prefilled }
663
+ }
664
+
665
+ // Proposal 4: auto-submit toast mode — compact view when ALL fields prefilled
666
+ const [expanded, setExpanded] = createSignal(false)
667
+ const allFieldsPrefilled = () => {
668
+ const fields = config().fields
669
+ return fields.length > 0 && fields.every((f: any) => f.prefill != null)
670
+ }
671
+ const showToast = () => allFieldsPrefilled() && !userInteracted() && !expanded() && countdown() != null
672
+
657
673
  return (
674
+ <Show when={!showToast()} fallback={
675
+ <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">
676
+ <span class="flex-1 text-blue-800 dark:text-blue-200 font-medium">
677
+ {config().fields.map((f: any) => f.displayHint || f.prefill).join(', ')}
678
+ </span>
679
+ <span class="text-blue-600 dark:text-blue-300">{countdown()}s...</span>
680
+ <button type="button" onClick={() => { setExpanded(true); cancelCountdown(); setUserInteracted(true) }}
681
+ class="text-blue-600 dark:text-blue-400 underline text-xs">Modifier</button>
682
+ <button type="button" onClick={() => { cancelCountdown(); setUserInteracted(true) }}
683
+ class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">&times;</button>
684
+ </div>
685
+ }>
658
686
  <form id={`scratchpad-form-${props.sectionId}`} onSubmit={handleSubmit} class="flex flex-col gap-3">
687
+ {/* Proposal 3: prefill summary */}
688
+ <Show when={prefillSummary().prefilled > 0}>
689
+ <p class="text-xs text-gray-500 dark:text-gray-400">
690
+ {prefillSummary().prefilled} champ{prefillSummary().prefilled > 1 ? 's' : ''} pré-rempli{prefillSummary().prefilled > 1 ? 's' : ''} sur {prefillSummary().total}
691
+ </p>
692
+ </Show>
659
693
  <For each={config().fields}>
660
694
  {(field) => (
661
695
  <FormFieldRenderer
@@ -686,6 +720,7 @@ const EmbeddedFormSection: Component<{
686
720
  </button>
687
721
  </div>
688
722
  </form>
723
+ </Show>
689
724
  )
690
725
  }
691
726
 
@@ -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