@seed-ship/mcp-ui-solid 2.5.2 → 2.6.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/components/ChatPrompt.cjs +76 -114
- package/dist/components/ChatPrompt.cjs.map +1 -1
- package/dist/components/ChatPrompt.d.ts +3 -1
- package/dist/components/ChatPrompt.d.ts.map +1 -1
- package/dist/components/ChatPrompt.js +78 -116
- package/dist/components/ChatPrompt.js.map +1 -1
- package/dist/components/FormFieldRenderer.cjs +232 -3
- package/dist/components/FormFieldRenderer.cjs.map +1 -1
- package/dist/components/FormFieldRenderer.d.ts.map +1 -1
- package/dist/components/FormFieldRenderer.js +233 -4
- package/dist/components/FormFieldRenderer.js.map +1 -1
- package/dist/types/chat-bus.d.ts +21 -1
- package/dist/types/chat-bus.d.ts.map +1 -1
- package/dist/types/index.d.ts +17 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types.d.cts +17 -1
- package/dist/types.d.ts +17 -1
- package/package.json +1 -1
- package/src/components/ChatPrompt.test.tsx +55 -0
- package/src/components/ChatPrompt.tsx +34 -53
- package/src/components/FormFieldRenderer.tsx +218 -3
- package/src/types/chat-bus.ts +21 -1
- package/src/types/index.ts +19 -0
- package/tsconfig.tsbuildinfo +1 -1
package/dist/types.d.cts
CHANGED
|
@@ -217,7 +217,7 @@ export interface FormFieldOption {
|
|
|
217
217
|
/**
|
|
218
218
|
* Form field type
|
|
219
219
|
*/
|
|
220
|
-
export type FormFieldType = 'text' | 'email' | 'password' | 'number' | 'date' | 'textarea' | 'select' | 'checkbox' | 'radio';
|
|
220
|
+
export type FormFieldType = 'text' | 'email' | 'password' | 'number' | 'date' | 'textarea' | 'select' | 'checkbox' | 'radio' | 'autocomplete';
|
|
221
221
|
/**
|
|
222
222
|
* Operators for conditional field display
|
|
223
223
|
*/
|
|
@@ -251,6 +251,22 @@ export interface FormFieldParams {
|
|
|
251
251
|
minDate?: string;
|
|
252
252
|
maxDate?: string;
|
|
253
253
|
options?: FormFieldOption[];
|
|
254
|
+
/** Enable multi-select (returns array of values) */
|
|
255
|
+
multiple?: boolean;
|
|
256
|
+
/** API URL for autocomplete suggestions */
|
|
257
|
+
apiUrl?: string;
|
|
258
|
+
/** Query parameter name (e.g. 'nom', 'q') */
|
|
259
|
+
searchParam?: string;
|
|
260
|
+
/** Field in API response to display */
|
|
261
|
+
labelField?: string;
|
|
262
|
+
/** Field in API response to use as value */
|
|
263
|
+
valueField?: string;
|
|
264
|
+
/** Extra query parameters */
|
|
265
|
+
extraParams?: Record<string, string>;
|
|
266
|
+
/** Min characters before triggering (default: 2) */
|
|
267
|
+
minChars?: number;
|
|
268
|
+
/** Debounce delay in ms (default: 300) */
|
|
269
|
+
debounceMs?: number;
|
|
254
270
|
checkboxLabel?: string;
|
|
255
271
|
rows?: number;
|
|
256
272
|
showWhen?: ShowWhenCondition;
|
package/dist/types.d.ts
CHANGED
|
@@ -217,7 +217,7 @@ export interface FormFieldOption {
|
|
|
217
217
|
/**
|
|
218
218
|
* Form field type
|
|
219
219
|
*/
|
|
220
|
-
export type FormFieldType = 'text' | 'email' | 'password' | 'number' | 'date' | 'textarea' | 'select' | 'checkbox' | 'radio';
|
|
220
|
+
export type FormFieldType = 'text' | 'email' | 'password' | 'number' | 'date' | 'textarea' | 'select' | 'checkbox' | 'radio' | 'autocomplete';
|
|
221
221
|
/**
|
|
222
222
|
* Operators for conditional field display
|
|
223
223
|
*/
|
|
@@ -251,6 +251,22 @@ export interface FormFieldParams {
|
|
|
251
251
|
minDate?: string;
|
|
252
252
|
maxDate?: string;
|
|
253
253
|
options?: FormFieldOption[];
|
|
254
|
+
/** Enable multi-select (returns array of values) */
|
|
255
|
+
multiple?: boolean;
|
|
256
|
+
/** API URL for autocomplete suggestions */
|
|
257
|
+
apiUrl?: string;
|
|
258
|
+
/** Query parameter name (e.g. 'nom', 'q') */
|
|
259
|
+
searchParam?: string;
|
|
260
|
+
/** Field in API response to display */
|
|
261
|
+
labelField?: string;
|
|
262
|
+
/** Field in API response to use as value */
|
|
263
|
+
valueField?: string;
|
|
264
|
+
/** Extra query parameters */
|
|
265
|
+
extraParams?: Record<string, string>;
|
|
266
|
+
/** Min characters before triggering (default: 2) */
|
|
267
|
+
minChars?: number;
|
|
268
|
+
/** Debounce delay in ms (default: 300) */
|
|
269
|
+
debounceMs?: number;
|
|
254
270
|
checkboxLabel?: string;
|
|
255
271
|
rows?: number;
|
|
256
272
|
showWhen?: ShowWhenCondition;
|
package/package.json
CHANGED
|
@@ -259,6 +259,61 @@ describe('ChatPrompt', () => {
|
|
|
259
259
|
})
|
|
260
260
|
})
|
|
261
261
|
|
|
262
|
+
// ─── dismissLabel ────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
describe('dismissLabel', () => {
|
|
265
|
+
it('shows X icon by default (no dismissLabel)', () => {
|
|
266
|
+
const config: ChatPromptConfig = {
|
|
267
|
+
type: 'choice',
|
|
268
|
+
title: 'Test',
|
|
269
|
+
config: { options: [{ value: 'a', label: 'A' }] },
|
|
270
|
+
}
|
|
271
|
+
const { getByLabelText } = render(() => (
|
|
272
|
+
<ChatPrompt config={config} onSubmit={() => {}} />
|
|
273
|
+
))
|
|
274
|
+
|
|
275
|
+
expect(getByLabelText('Dismiss')).toBeDefined()
|
|
276
|
+
expect(getByLabelText('Dismiss').querySelector('svg')).not.toBeNull()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('shows text button when dismissLabel is provided', () => {
|
|
280
|
+
const config: ChatPromptConfig = {
|
|
281
|
+
type: 'choice',
|
|
282
|
+
title: 'Test',
|
|
283
|
+
config: { options: [{ value: 'a', label: 'A' }] },
|
|
284
|
+
}
|
|
285
|
+
const { getByText, getByLabelText } = render(() => (
|
|
286
|
+
<ChatPrompt config={config} onSubmit={() => {}} dismissLabel="Send as-is" />
|
|
287
|
+
))
|
|
288
|
+
|
|
289
|
+
expect(getByText('Send as-is')).toBeDefined()
|
|
290
|
+
expect(getByLabelText('Send as-is')).toBeDefined()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('calls onDismiss + onSubmit when dismissLabel button clicked', () => {
|
|
294
|
+
const onSubmit = vi.fn()
|
|
295
|
+
const onDismiss = vi.fn()
|
|
296
|
+
const config: ChatPromptConfig = {
|
|
297
|
+
type: 'choice',
|
|
298
|
+
title: 'Test',
|
|
299
|
+
config: { options: [{ value: 'a', label: 'A' }] },
|
|
300
|
+
}
|
|
301
|
+
const { getByText } = render(() => (
|
|
302
|
+
<ChatPrompt config={config} onSubmit={onSubmit} onDismiss={onDismiss} dismissLabel="Envoyer directement" />
|
|
303
|
+
))
|
|
304
|
+
|
|
305
|
+
fireEvent.click(getByText('Envoyer directement'))
|
|
306
|
+
|
|
307
|
+
expect(onDismiss).toHaveBeenCalled()
|
|
308
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
309
|
+
type: 'choice',
|
|
310
|
+
value: '',
|
|
311
|
+
label: '',
|
|
312
|
+
dismissed: true,
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
|
|
262
317
|
// ─── Null guard (F1) ──────────────────────────────────
|
|
263
318
|
|
|
264
319
|
describe('null guard', () => {
|
|
@@ -16,14 +16,18 @@ import type {
|
|
|
16
16
|
ConfirmPromptConfig,
|
|
17
17
|
FormPromptConfig,
|
|
18
18
|
} from '../types/chat-bus'
|
|
19
|
+
import { FormFieldRenderer } from './FormFieldRenderer'
|
|
20
|
+
import type { FormFieldParams } from '../types'
|
|
19
21
|
|
|
20
22
|
export interface ChatPromptProps {
|
|
21
23
|
/** Prompt configuration */
|
|
22
24
|
config: ChatPromptConfig
|
|
23
25
|
/** Called when user responds */
|
|
24
26
|
onSubmit: (response: ChatPromptResponse) => void
|
|
25
|
-
/** Called when user dismisses */
|
|
27
|
+
/** Called when user dismisses (e.g. "send as-is") */
|
|
26
28
|
onDismiss?: () => void
|
|
29
|
+
/** Label for the dismiss button (replaces X icon). Default: shows X icon. */
|
|
30
|
+
dismissLabel?: string
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/**
|
|
@@ -57,12 +61,19 @@ export const ChatPrompt: Component<ChatPromptProps> = (props) => {
|
|
|
57
61
|
props.onDismiss?.()
|
|
58
62
|
props.onSubmit({ type: props.config.type, value: '', label: '', dismissed: true })
|
|
59
63
|
}}
|
|
60
|
-
class=
|
|
61
|
-
|
|
64
|
+
class={props.dismissLabel
|
|
65
|
+
? 'px-3 py-1 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors'
|
|
66
|
+
: 'p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors'
|
|
67
|
+
}
|
|
68
|
+
aria-label={props.dismissLabel || 'Dismiss'}
|
|
62
69
|
>
|
|
63
|
-
<
|
|
64
|
-
<
|
|
65
|
-
|
|
70
|
+
<Show when={props.dismissLabel} fallback={
|
|
71
|
+
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
72
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
73
|
+
</svg>
|
|
74
|
+
}>
|
|
75
|
+
{props.dismissLabel}
|
|
76
|
+
</Show>
|
|
66
77
|
</button>
|
|
67
78
|
</div>
|
|
68
79
|
|
|
@@ -176,25 +187,24 @@ const ConfirmBody: Component<{
|
|
|
176
187
|
)
|
|
177
188
|
}
|
|
178
189
|
|
|
179
|
-
// ─── Form
|
|
190
|
+
// ─── Form (delegates to FormFieldRenderer for all field types) ───
|
|
180
191
|
|
|
181
192
|
const FormBody: Component<{
|
|
182
193
|
config: FormPromptConfig
|
|
183
194
|
onSubmit: (data: Record<string, unknown>, label: string) => void
|
|
184
195
|
}> = (props) => {
|
|
185
|
-
const [formData, setFormData] = createSignal<Record<string,
|
|
196
|
+
const [formData, setFormData] = createSignal<Record<string, any>>({})
|
|
186
197
|
|
|
187
|
-
const updateField = (name: string, value:
|
|
198
|
+
const updateField = (name: string, value: any) => {
|
|
188
199
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
|
189
200
|
}
|
|
190
201
|
|
|
191
202
|
const handleSubmit = (e: Event) => {
|
|
192
203
|
e.preventDefault()
|
|
193
204
|
const data = formData()
|
|
194
|
-
// Build a human-readable label from the form values
|
|
195
205
|
const label = Object.entries(data)
|
|
196
|
-
.filter(([, v]) => v)
|
|
197
|
-
.map(([k, v]) => `${k}: ${v}`)
|
|
206
|
+
.filter(([, v]) => v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0))
|
|
207
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
|
|
198
208
|
.join(', ')
|
|
199
209
|
props.onSubmit(data, label || 'Form submitted')
|
|
200
210
|
}
|
|
@@ -203,53 +213,24 @@ const FormBody: Component<{
|
|
|
203
213
|
const data = formData()
|
|
204
214
|
return (props.config.fields || [])
|
|
205
215
|
.filter((f) => f.required)
|
|
206
|
-
.every((f) =>
|
|
216
|
+
.every((f) => {
|
|
217
|
+
const val = data[f.name]
|
|
218
|
+
if (Array.isArray(val)) return val.length > 0
|
|
219
|
+
if (typeof val === 'boolean') return true
|
|
220
|
+
return val !== undefined && val !== ''
|
|
221
|
+
})
|
|
207
222
|
}
|
|
208
223
|
|
|
209
224
|
return (
|
|
210
225
|
<form onSubmit={handleSubmit} class="flex flex-col gap-3">
|
|
211
226
|
<For each={props.config.fields}>
|
|
212
227
|
{(field) => (
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
</label>
|
|
220
|
-
<Switch>
|
|
221
|
-
<Match when={field.type === 'textarea'}>
|
|
222
|
-
<textarea
|
|
223
|
-
value={formData()[field.name] || ''}
|
|
224
|
-
onInput={(e) => updateField(field.name, e.currentTarget.value)}
|
|
225
|
-
placeholder={field.placeholder}
|
|
226
|
-
rows={3}
|
|
227
|
-
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-400 focus:ring-1 focus:ring-blue-400 outline-none transition-colors"
|
|
228
|
-
/>
|
|
229
|
-
</Match>
|
|
230
|
-
<Match when={field.type === 'select'}>
|
|
231
|
-
<select
|
|
232
|
-
value={formData()[field.name] || ''}
|
|
233
|
-
onChange={(e) => updateField(field.name, e.currentTarget.value)}
|
|
234
|
-
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-400 focus:ring-1 focus:ring-blue-400 outline-none transition-colors"
|
|
235
|
-
>
|
|
236
|
-
<option value="">{field.placeholder || 'Select...'}</option>
|
|
237
|
-
<For each={field.options}>
|
|
238
|
-
{(opt) => <option value={opt.value}>{opt.label}</option>}
|
|
239
|
-
</For>
|
|
240
|
-
</select>
|
|
241
|
-
</Match>
|
|
242
|
-
<Match when={true}>
|
|
243
|
-
<input
|
|
244
|
-
type={field.type === 'number' ? 'number' : 'text'}
|
|
245
|
-
value={formData()[field.name] || ''}
|
|
246
|
-
onInput={(e) => updateField(field.name, e.currentTarget.value)}
|
|
247
|
-
placeholder={field.placeholder}
|
|
248
|
-
class="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-400 focus:ring-1 focus:ring-blue-400 outline-none transition-colors"
|
|
249
|
-
/>
|
|
250
|
-
</Match>
|
|
251
|
-
</Switch>
|
|
252
|
-
</div>
|
|
228
|
+
<FormFieldRenderer
|
|
229
|
+
field={field as FormFieldParams}
|
|
230
|
+
value={formData()[field.name]}
|
|
231
|
+
onChange={(val) => updateField(field.name, val)}
|
|
232
|
+
formData={formData}
|
|
233
|
+
/>
|
|
253
234
|
)}
|
|
254
235
|
</For>
|
|
255
236
|
<div class="flex justify-end">
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Sprint 2: Conditional field visibility (showWhen)
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { Component, Show, For, Switch, Match, Accessor } from 'solid-js'
|
|
7
|
+
import { Component, Show, For, Switch, Match, Accessor, createSignal, createEffect, onCleanup } from 'solid-js'
|
|
8
8
|
import type { FormFieldParams } from '../types'
|
|
9
9
|
import { useConditionalField } from '../hooks/useConditionalField'
|
|
10
10
|
|
|
@@ -133,8 +133,8 @@ export const FormFieldRenderer: Component<FormFieldRendererProps> = (props) => {
|
|
|
133
133
|
/>
|
|
134
134
|
</Match>
|
|
135
135
|
|
|
136
|
-
{/* Select */}
|
|
137
|
-
<Match when={props.field.type === 'select'}>
|
|
136
|
+
{/* Select (single) */}
|
|
137
|
+
<Match when={props.field.type === 'select' && !props.field.multiple}>
|
|
138
138
|
<select
|
|
139
139
|
id={fieldId()}
|
|
140
140
|
name={props.field.name}
|
|
@@ -182,6 +182,28 @@ export const FormFieldRenderer: Component<FormFieldRendererProps> = (props) => {
|
|
|
182
182
|
</label>
|
|
183
183
|
</Match>
|
|
184
184
|
|
|
185
|
+
{/* Multi-Select with chips */}
|
|
186
|
+
<Match when={props.field.type === 'select' && props.field.multiple}>
|
|
187
|
+
<MultiSelectField
|
|
188
|
+
field={props.field}
|
|
189
|
+
value={props.value || []}
|
|
190
|
+
onChange={props.onChange}
|
|
191
|
+
disabled={props.disabled}
|
|
192
|
+
baseClass={baseInputClass()}
|
|
193
|
+
/>
|
|
194
|
+
</Match>
|
|
195
|
+
|
|
196
|
+
{/* Autocomplete with API fetch */}
|
|
197
|
+
<Match when={props.field.type === 'autocomplete'}>
|
|
198
|
+
<AutocompleteField
|
|
199
|
+
field={props.field}
|
|
200
|
+
value={props.value || ''}
|
|
201
|
+
onChange={props.onChange}
|
|
202
|
+
disabled={props.disabled}
|
|
203
|
+
baseClass={baseInputClass()}
|
|
204
|
+
/>
|
|
205
|
+
</Match>
|
|
206
|
+
|
|
185
207
|
{/* Radio Group */}
|
|
186
208
|
<Match when={props.field.type === 'radio'}>
|
|
187
209
|
<div
|
|
@@ -227,3 +249,196 @@ export const FormFieldRenderer: Component<FormFieldRendererProps> = (props) => {
|
|
|
227
249
|
</Show>
|
|
228
250
|
)
|
|
229
251
|
}
|
|
252
|
+
|
|
253
|
+
// ─── Multi-Select with Chips ─────────────────────────────────
|
|
254
|
+
|
|
255
|
+
const MultiSelectField: Component<{
|
|
256
|
+
field: FormFieldParams
|
|
257
|
+
value: string[]
|
|
258
|
+
onChange: (value: string[]) => void
|
|
259
|
+
disabled?: boolean
|
|
260
|
+
baseClass: string
|
|
261
|
+
}> = (props) => {
|
|
262
|
+
const [open, setOpen] = createSignal(false)
|
|
263
|
+
|
|
264
|
+
const toggle = (val: string) => {
|
|
265
|
+
const current = props.value || []
|
|
266
|
+
if (current.includes(val)) {
|
|
267
|
+
props.onChange(current.filter((v) => v !== val))
|
|
268
|
+
} else {
|
|
269
|
+
props.onChange([...current, val])
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const removeChip = (val: string) => {
|
|
274
|
+
props.onChange((props.value || []).filter((v) => v !== val))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const getLabel = (val: string) =>
|
|
278
|
+
props.field.options?.find((o) => o.value === val)?.label || val
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div class="relative">
|
|
282
|
+
{/* Selected chips */}
|
|
283
|
+
<Show when={props.value.length > 0}>
|
|
284
|
+
<div class="flex flex-wrap gap-1 mb-1">
|
|
285
|
+
<For each={props.value}>
|
|
286
|
+
{(val) => (
|
|
287
|
+
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">
|
|
288
|
+
{getLabel(val)}
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
onClick={() => removeChip(val)}
|
|
292
|
+
class="hover:text-blue-900 dark:hover:text-blue-100"
|
|
293
|
+
aria-label={`Remove ${getLabel(val)}`}
|
|
294
|
+
>
|
|
295
|
+
×
|
|
296
|
+
</button>
|
|
297
|
+
</span>
|
|
298
|
+
)}
|
|
299
|
+
</For>
|
|
300
|
+
</div>
|
|
301
|
+
</Show>
|
|
302
|
+
|
|
303
|
+
{/* Trigger button */}
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
onClick={() => setOpen(!open())}
|
|
307
|
+
disabled={props.disabled}
|
|
308
|
+
class={`${props.baseClass} text-left flex items-center justify-between`}
|
|
309
|
+
>
|
|
310
|
+
<span class={props.value.length ? 'text-gray-900 dark:text-white' : 'text-gray-400'}>
|
|
311
|
+
{props.value.length
|
|
312
|
+
? `${props.value.length} selected`
|
|
313
|
+
: props.field.placeholder || 'Select...'}
|
|
314
|
+
</span>
|
|
315
|
+
<svg class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
316
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
317
|
+
</svg>
|
|
318
|
+
</button>
|
|
319
|
+
|
|
320
|
+
{/* Dropdown */}
|
|
321
|
+
<Show when={open()}>
|
|
322
|
+
<div class="absolute z-20 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-auto">
|
|
323
|
+
<For each={props.field.options}>
|
|
324
|
+
{(option) => (
|
|
325
|
+
<label class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer text-sm">
|
|
326
|
+
<input
|
|
327
|
+
type="checkbox"
|
|
328
|
+
checked={(props.value || []).includes(option.value)}
|
|
329
|
+
onChange={() => toggle(option.value)}
|
|
330
|
+
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
|
|
331
|
+
/>
|
|
332
|
+
<span class="text-gray-900 dark:text-white">{option.label}</span>
|
|
333
|
+
</label>
|
|
334
|
+
)}
|
|
335
|
+
</For>
|
|
336
|
+
</div>
|
|
337
|
+
</Show>
|
|
338
|
+
</div>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Autocomplete with API fetch ─────────────────────────────
|
|
343
|
+
|
|
344
|
+
const AutocompleteField: Component<{
|
|
345
|
+
field: FormFieldParams
|
|
346
|
+
value: string
|
|
347
|
+
onChange: (value: string) => void
|
|
348
|
+
disabled?: boolean
|
|
349
|
+
baseClass: string
|
|
350
|
+
}> = (props) => {
|
|
351
|
+
const [query, setQuery] = createSignal('')
|
|
352
|
+
const [suggestions, setSuggestions] = createSignal<Array<{ label: string; value: string }>>([])
|
|
353
|
+
const [isOpen, setIsOpen] = createSignal(false)
|
|
354
|
+
const [selectedLabel, setSelectedLabel] = createSignal('')
|
|
355
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
356
|
+
|
|
357
|
+
const minChars = () => props.field.minChars ?? 2
|
|
358
|
+
const debounceMs = () => props.field.debounceMs ?? 300
|
|
359
|
+
|
|
360
|
+
const fetchSuggestions = async (q: string) => {
|
|
361
|
+
if (!props.field.apiUrl || !props.field.searchParam) return
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const params = new URLSearchParams({ [props.field.searchParam]: q })
|
|
365
|
+
if (props.field.extraParams) {
|
|
366
|
+
for (const [k, v] of Object.entries(props.field.extraParams)) {
|
|
367
|
+
params.set(k, v)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const res = await fetch(`${props.field.apiUrl}?${params}`)
|
|
371
|
+
if (!res.ok) return
|
|
372
|
+
|
|
373
|
+
const data = await res.json()
|
|
374
|
+
const items = Array.isArray(data) ? data : data.results || data.features || []
|
|
375
|
+
const labelField = props.field.labelField || 'label'
|
|
376
|
+
const valueField = props.field.valueField || 'value'
|
|
377
|
+
|
|
378
|
+
setSuggestions(items.slice(0, 10).map((item: any) => ({
|
|
379
|
+
label: item[labelField] || String(item),
|
|
380
|
+
value: String(item[valueField] || item[labelField] || item),
|
|
381
|
+
})))
|
|
382
|
+
setIsOpen(true)
|
|
383
|
+
} catch {
|
|
384
|
+
setSuggestions([])
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const handleInput = (value: string) => {
|
|
389
|
+
setQuery(value)
|
|
390
|
+
setSelectedLabel('')
|
|
391
|
+
props.onChange('')
|
|
392
|
+
|
|
393
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
394
|
+
if (value.length < minChars()) {
|
|
395
|
+
setSuggestions([])
|
|
396
|
+
setIsOpen(false)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
debounceTimer = setTimeout(() => fetchSuggestions(value), debounceMs())
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const selectSuggestion = (item: { label: string; value: string }) => {
|
|
403
|
+
props.onChange(item.value)
|
|
404
|
+
setSelectedLabel(item.label)
|
|
405
|
+
setQuery(item.label)
|
|
406
|
+
setIsOpen(false)
|
|
407
|
+
setSuggestions([])
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
onCleanup(() => { if (debounceTimer) clearTimeout(debounceTimer) })
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<div class="relative">
|
|
414
|
+
<input
|
|
415
|
+
type="text"
|
|
416
|
+
value={query()}
|
|
417
|
+
onInput={(e) => handleInput(e.currentTarget.value)}
|
|
418
|
+
onFocus={() => { if (suggestions().length) setIsOpen(true) }}
|
|
419
|
+
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
|
|
420
|
+
placeholder={props.field.placeholder}
|
|
421
|
+
disabled={props.disabled}
|
|
422
|
+
class={props.baseClass}
|
|
423
|
+
autocomplete="off"
|
|
424
|
+
/>
|
|
425
|
+
|
|
426
|
+
<Show when={isOpen() && suggestions().length > 0}>
|
|
427
|
+
<div class="absolute z-20 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg max-h-48 overflow-auto">
|
|
428
|
+
<For each={suggestions()}>
|
|
429
|
+
{(item) => (
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
433
|
+
onClick={() => selectSuggestion(item)}
|
|
434
|
+
class="w-full text-left px-3 py-2 text-sm hover:bg-blue-50 dark:hover:bg-blue-900/20 text-gray-900 dark:text-white"
|
|
435
|
+
>
|
|
436
|
+
{item.label}
|
|
437
|
+
</button>
|
|
438
|
+
)}
|
|
439
|
+
</For>
|
|
440
|
+
</div>
|
|
441
|
+
</Show>
|
|
442
|
+
</div>
|
|
443
|
+
)
|
|
444
|
+
}
|
package/src/types/chat-bus.ts
CHANGED
|
@@ -197,10 +197,30 @@ export interface FormPromptConfig {
|
|
|
197
197
|
fields: Array<{
|
|
198
198
|
name: string
|
|
199
199
|
label: string
|
|
200
|
-
type: 'text' | 'number' | 'select' | 'textarea'
|
|
200
|
+
type: 'text' | 'number' | 'select' | 'textarea' | 'checkbox' | 'radio' | 'date' | 'email' | 'autocomplete'
|
|
201
201
|
required?: boolean
|
|
202
202
|
placeholder?: string
|
|
203
203
|
options?: Array<{ label: string; value: string }>
|
|
204
|
+
/** Enable multi-select (returns array) */
|
|
205
|
+
multiple?: boolean
|
|
206
|
+
/** Autocomplete: API URL */
|
|
207
|
+
apiUrl?: string
|
|
208
|
+
/** Autocomplete: query param name */
|
|
209
|
+
searchParam?: string
|
|
210
|
+
/** Autocomplete: field in response for display */
|
|
211
|
+
labelField?: string
|
|
212
|
+
/** Autocomplete: field in response for value */
|
|
213
|
+
valueField?: string
|
|
214
|
+
/** Autocomplete: extra query params */
|
|
215
|
+
extraParams?: Record<string, string>
|
|
216
|
+
/** Autocomplete: min chars before trigger (default: 2) */
|
|
217
|
+
minChars?: number
|
|
218
|
+
/** Autocomplete: debounce ms (default: 300) */
|
|
219
|
+
debounceMs?: number
|
|
220
|
+
/** Checkbox label text */
|
|
221
|
+
checkboxLabel?: string
|
|
222
|
+
/** Help text below field */
|
|
223
|
+
helpText?: string
|
|
204
224
|
}>
|
|
205
225
|
submitLabel?: string
|
|
206
226
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -267,6 +267,7 @@ export type FormFieldType =
|
|
|
267
267
|
| 'select'
|
|
268
268
|
| 'checkbox'
|
|
269
269
|
| 'radio'
|
|
270
|
+
| 'autocomplete'
|
|
270
271
|
|
|
271
272
|
/**
|
|
272
273
|
* Operators for conditional field display
|
|
@@ -324,6 +325,24 @@ export interface FormFieldParams {
|
|
|
324
325
|
|
|
325
326
|
// Select/Radio specific
|
|
326
327
|
options?: FormFieldOption[]
|
|
328
|
+
/** Enable multi-select (returns array of values) */
|
|
329
|
+
multiple?: boolean
|
|
330
|
+
|
|
331
|
+
// Autocomplete specific
|
|
332
|
+
/** API URL for autocomplete suggestions */
|
|
333
|
+
apiUrl?: string
|
|
334
|
+
/** Query parameter name (e.g. 'nom', 'q') */
|
|
335
|
+
searchParam?: string
|
|
336
|
+
/** Field in API response to display */
|
|
337
|
+
labelField?: string
|
|
338
|
+
/** Field in API response to use as value */
|
|
339
|
+
valueField?: string
|
|
340
|
+
/** Extra query parameters */
|
|
341
|
+
extraParams?: Record<string, string>
|
|
342
|
+
/** Min characters before triggering (default: 2) */
|
|
343
|
+
minChars?: number
|
|
344
|
+
/** Debounce delay in ms (default: 300) */
|
|
345
|
+
debounceMs?: number
|
|
327
346
|
|
|
328
347
|
// Checkbox specific
|
|
329
348
|
checkboxLabel?: string
|