@seed-ship/mcp-ui-solid 2.0.0 → 2.1.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/README.md +50 -1
- package/dist/components/AutocompleteDropdown.cjs +201 -0
- package/dist/components/AutocompleteDropdown.cjs.map +1 -0
- package/dist/components/AutocompleteDropdown.d.ts +71 -0
- package/dist/components/AutocompleteDropdown.d.ts.map +1 -0
- package/dist/components/AutocompleteDropdown.js +201 -0
- package/dist/components/AutocompleteDropdown.js.map +1 -0
- package/dist/components/AutocompleteFormField.cjs +289 -0
- package/dist/components/AutocompleteFormField.cjs.map +1 -0
- package/dist/components/AutocompleteFormField.d.ts +52 -0
- package/dist/components/AutocompleteFormField.d.ts.map +1 -0
- package/dist/components/AutocompleteFormField.js +289 -0
- package/dist/components/AutocompleteFormField.js.map +1 -0
- package/dist/components/DraggableGridItem.cjs +133 -0
- package/dist/components/DraggableGridItem.cjs.map +1 -0
- package/dist/components/DraggableGridItem.d.ts +95 -0
- package/dist/components/DraggableGridItem.d.ts.map +1 -0
- package/dist/components/DraggableGridItem.js +133 -0
- package/dist/components/DraggableGridItem.js.map +1 -0
- package/dist/components/EditableUIResourceRenderer.cjs +203 -0
- package/dist/components/EditableUIResourceRenderer.cjs.map +1 -0
- package/dist/components/EditableUIResourceRenderer.d.ts +43 -0
- package/dist/components/EditableUIResourceRenderer.d.ts.map +1 -0
- package/dist/components/EditableUIResourceRenderer.js +203 -0
- package/dist/components/EditableUIResourceRenderer.js.map +1 -0
- package/dist/components/GhostText.cjs +105 -0
- package/dist/components/GhostText.cjs.map +1 -0
- package/dist/components/GhostText.d.ts +113 -0
- package/dist/components/GhostText.d.ts.map +1 -0
- package/dist/components/GhostText.js +105 -0
- package/dist/components/GhostText.js.map +1 -0
- package/dist/components/ResizeHandle.cjs +173 -0
- package/dist/components/ResizeHandle.cjs.map +1 -0
- package/dist/components/ResizeHandle.d.ts +50 -0
- package/dist/components/ResizeHandle.d.ts.map +1 -0
- package/dist/components/ResizeHandle.js +173 -0
- package/dist/components/ResizeHandle.js.map +1 -0
- package/dist/context/AutocompleteContext.cjs +158 -0
- package/dist/context/AutocompleteContext.cjs.map +1 -0
- package/dist/context/AutocompleteContext.d.ts +77 -0
- package/dist/context/AutocompleteContext.d.ts.map +1 -0
- package/dist/context/AutocompleteContext.js +158 -0
- package/dist/context/AutocompleteContext.js.map +1 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useAutocomplete.cjs +234 -0
- package/dist/hooks/useAutocomplete.cjs.map +1 -0
- package/dist/hooks/useAutocomplete.d.ts +119 -0
- package/dist/hooks/useAutocomplete.d.ts.map +1 -0
- package/dist/hooks/useAutocomplete.js +234 -0
- package/dist/hooks/useAutocomplete.js.map +1 -0
- package/dist/hooks/useDragDrop.cjs +170 -0
- package/dist/hooks/useDragDrop.cjs.map +1 -0
- package/dist/hooks/useDragDrop.d.ts +100 -0
- package/dist/hooks/useDragDrop.d.ts.map +1 -0
- package/dist/hooks/useDragDrop.js +170 -0
- package/dist/hooks/useDragDrop.js.map +1 -0
- package/dist/hooks/useResize.cjs +209 -0
- package/dist/hooks/useResize.cjs.map +1 -0
- package/dist/hooks/useResize.d.ts +87 -0
- package/dist/hooks/useResize.d.ts.map +1 -0
- package/dist/hooks/useResize.js +209 -0
- package/dist/hooks/useResize.js.map +1 -0
- package/dist/hooks.cjs +6 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.d.cts +6 -0
- package/dist/hooks.d.ts +6 -0
- package/dist/hooks.js +6 -0
- package/dist/hooks.js.map +1 -1
- package/dist/index.cjs +29 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -3
- package/dist/index.d.ts +18 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/plugins/duckdb.cjs +192 -0
- package/dist/plugins/duckdb.cjs.map +1 -0
- package/dist/plugins/duckdb.d.ts +20 -0
- package/dist/plugins/duckdb.d.ts.map +1 -0
- package/dist/plugins/duckdb.js +170 -0
- package/dist/plugins/duckdb.js.map +1 -0
- package/dist/plugins/groq.cjs +97 -0
- package/dist/plugins/groq.cjs.map +1 -0
- package/dist/plugins/groq.d.ts +13 -0
- package/dist/plugins/groq.d.ts.map +1 -0
- package/dist/plugins/groq.js +97 -0
- package/dist/plugins/groq.js.map +1 -0
- package/dist/plugins/index.d.ts +10 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/rest.cjs +92 -0
- package/dist/plugins/rest.cjs.map +1 -0
- package/dist/plugins/rest.d.ts +13 -0
- package/dist/plugins/rest.d.ts.map +1 -0
- package/dist/plugins/rest.js +92 -0
- package/dist/plugins/rest.js.map +1 -0
- package/dist/plugins/supabase.cjs +79 -0
- package/dist/plugins/supabase.cjs.map +1 -0
- package/dist/plugins/supabase.d.ts +13 -0
- package/dist/plugins/supabase.d.ts.map +1 -0
- package/dist/plugins/supabase.js +79 -0
- package/dist/plugins/supabase.js.map +1 -0
- package/dist/services/validation.cjs +40 -1
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +40 -1
- package/dist/services/validation.js.map +1 -1
- package/dist/types/index.d.ts +430 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types.d.cts +430 -0
- package/dist/types.d.ts +430 -0
- package/package.json +16 -1
- package/src/components/AutocompleteDropdown.tsx +329 -0
- package/src/components/AutocompleteFormField.tsx +288 -0
- package/src/components/DraggableGridItem.tsx +274 -0
- package/src/components/EditableUIResourceRenderer.tsx +273 -0
- package/src/components/GhostText.tsx +262 -0
- package/src/components/ResizeHandle.tsx +262 -0
- package/src/context/AutocompleteContext.tsx +317 -0
- package/src/hooks/index.ts +23 -0
- package/src/hooks/useAutocomplete.test.ts +334 -0
- package/src/hooks/useAutocomplete.ts +466 -0
- package/src/hooks/useDragDrop.test.ts +355 -0
- package/src/hooks/useDragDrop.ts +379 -0
- package/src/hooks/useResize.test.ts +313 -0
- package/src/hooks/useResize.ts +372 -0
- package/src/index.ts +71 -0
- package/src/plugins/duckdb.ts +269 -0
- package/src/plugins/groq.ts +137 -0
- package/src/plugins/index.ts +14 -0
- package/src/plugins/rest.ts +147 -0
- package/src/plugins/supabase.ts +120 -0
- package/src/services/validation.ts +46 -0
- package/src/styles/autocomplete.css +356 -0
- package/src/styles/drag-drop.css +297 -0
- package/src/styles/index.css +7 -0
- package/src/types/index.ts +529 -0
- package/src/vite-env.d.ts +18 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vite.config.ts +2 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutocompleteDropdown Component
|
|
3
|
+
* Displays data-driven dropdown suggestions
|
|
4
|
+
*
|
|
5
|
+
* Sprint Autocomplete Feature
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Component, For, Show, createMemo, JSX } from 'solid-js'
|
|
9
|
+
import type { AutocompleteOption } from '../types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props for AutocompleteDropdown
|
|
13
|
+
*/
|
|
14
|
+
export interface AutocompleteDropdownProps {
|
|
15
|
+
/**
|
|
16
|
+
* Options to display
|
|
17
|
+
*/
|
|
18
|
+
options: AutocompleteOption[]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Currently selected index
|
|
22
|
+
*/
|
|
23
|
+
selectedIndex: number
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether dropdown is visible
|
|
27
|
+
*/
|
|
28
|
+
isOpen: boolean
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Callback when option is selected
|
|
32
|
+
*/
|
|
33
|
+
onSelect: (option: AutocompleteOption) => void
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Callback when option is hovered
|
|
37
|
+
*/
|
|
38
|
+
onHover?: (index: number) => void
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether loading
|
|
42
|
+
*/
|
|
43
|
+
isLoading?: boolean
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Custom class
|
|
47
|
+
*/
|
|
48
|
+
class?: string
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Max height
|
|
52
|
+
*/
|
|
53
|
+
maxHeight?: string
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Empty state message
|
|
57
|
+
*/
|
|
58
|
+
emptyMessage?: string
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Loading message
|
|
62
|
+
*/
|
|
63
|
+
loadingMessage?: string
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Highlight matching text in options
|
|
67
|
+
*/
|
|
68
|
+
highlightMatch?: string
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Position (default: 'bottom')
|
|
72
|
+
*/
|
|
73
|
+
position?: 'top' | 'bottom'
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Custom option renderer
|
|
77
|
+
*/
|
|
78
|
+
renderOption?: (option: AutocompleteOption, isSelected: boolean) => JSX.Element
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Highlight matching text
|
|
83
|
+
*/
|
|
84
|
+
function highlightText(text: string, match?: string): JSX.Element {
|
|
85
|
+
if (!match || !text) {
|
|
86
|
+
return <>{text}</>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const lowerText = text.toLowerCase()
|
|
90
|
+
const lowerMatch = match.toLowerCase()
|
|
91
|
+
const startIndex = lowerText.indexOf(lowerMatch)
|
|
92
|
+
|
|
93
|
+
if (startIndex === -1) {
|
|
94
|
+
return <>{text}</>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const before = text.slice(0, startIndex)
|
|
98
|
+
const matched = text.slice(startIndex, startIndex + match.length)
|
|
99
|
+
const after = text.slice(startIndex + match.length)
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
{before}
|
|
104
|
+
<strong class="mcp-autocomplete-highlight">{matched}</strong>
|
|
105
|
+
{after}
|
|
106
|
+
</>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Default option renderer
|
|
112
|
+
*/
|
|
113
|
+
const DefaultOptionRenderer: Component<{
|
|
114
|
+
option: AutocompleteOption
|
|
115
|
+
isSelected: boolean
|
|
116
|
+
highlightMatch?: string
|
|
117
|
+
}> = (props) => {
|
|
118
|
+
const displayLabel = createMemo(() =>
|
|
119
|
+
props.option.label || props.option.value
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div class="mcp-autocomplete-option-content">
|
|
124
|
+
<Show when={props.option.icon}>
|
|
125
|
+
<span class="mcp-autocomplete-option-icon">{props.option.icon}</span>
|
|
126
|
+
</Show>
|
|
127
|
+
<div class="mcp-autocomplete-option-text">
|
|
128
|
+
<span class="mcp-autocomplete-option-label">
|
|
129
|
+
{highlightText(displayLabel(), props.highlightMatch)}
|
|
130
|
+
</span>
|
|
131
|
+
<Show when={props.option.description}>
|
|
132
|
+
<span class="mcp-autocomplete-option-description">
|
|
133
|
+
{props.option.description}
|
|
134
|
+
</span>
|
|
135
|
+
</Show>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* AutocompleteDropdown Component
|
|
143
|
+
*/
|
|
144
|
+
export const AutocompleteDropdown: Component<AutocompleteDropdownProps> = (props) => {
|
|
145
|
+
const positionStyles = createMemo((): JSX.CSSProperties => {
|
|
146
|
+
if (props.position === 'top') {
|
|
147
|
+
return {
|
|
148
|
+
bottom: '100%',
|
|
149
|
+
'margin-bottom': '4px'
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
top: '100%',
|
|
154
|
+
'margin-top': '4px'
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const containerStyles = createMemo((): JSX.CSSProperties => ({
|
|
159
|
+
position: 'absolute',
|
|
160
|
+
left: '0',
|
|
161
|
+
right: '0',
|
|
162
|
+
'z-index': '50',
|
|
163
|
+
'background-color': '#ffffff',
|
|
164
|
+
border: '1px solid #e5e7eb',
|
|
165
|
+
'border-radius': '6px',
|
|
166
|
+
'box-shadow': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
|
167
|
+
'max-height': props.maxHeight || '240px',
|
|
168
|
+
overflow: 'auto',
|
|
169
|
+
...positionStyles()
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Show when={props.isOpen}>
|
|
174
|
+
<div
|
|
175
|
+
class={`mcp-autocomplete-dropdown ${props.class || ''}`}
|
|
176
|
+
style={containerStyles()}
|
|
177
|
+
role="listbox"
|
|
178
|
+
aria-label="Suggestions"
|
|
179
|
+
>
|
|
180
|
+
{/* Loading state */}
|
|
181
|
+
<Show when={props.isLoading}>
|
|
182
|
+
<div
|
|
183
|
+
class="mcp-autocomplete-loading"
|
|
184
|
+
style={{
|
|
185
|
+
padding: '12px 16px',
|
|
186
|
+
color: '#6b7280',
|
|
187
|
+
'font-size': '0.875rem',
|
|
188
|
+
display: 'flex',
|
|
189
|
+
'align-items': 'center',
|
|
190
|
+
gap: '8px'
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
<span
|
|
194
|
+
style={{
|
|
195
|
+
display: 'inline-block',
|
|
196
|
+
width: '14px',
|
|
197
|
+
height: '14px',
|
|
198
|
+
border: '2px solid #e5e7eb',
|
|
199
|
+
'border-top-color': '#3b82f6',
|
|
200
|
+
'border-radius': '50%',
|
|
201
|
+
animation: 'mcp-spin 0.6s linear infinite'
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
{props.loadingMessage || 'Loading...'}
|
|
205
|
+
</div>
|
|
206
|
+
</Show>
|
|
207
|
+
|
|
208
|
+
{/* Empty state */}
|
|
209
|
+
<Show when={!props.isLoading && props.options.length === 0}>
|
|
210
|
+
<div
|
|
211
|
+
class="mcp-autocomplete-empty"
|
|
212
|
+
style={{
|
|
213
|
+
padding: '12px 16px',
|
|
214
|
+
color: '#9ca3af',
|
|
215
|
+
'font-size': '0.875rem',
|
|
216
|
+
'text-align': 'center'
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{props.emptyMessage || 'No suggestions found'}
|
|
220
|
+
</div>
|
|
221
|
+
</Show>
|
|
222
|
+
|
|
223
|
+
{/* Options list */}
|
|
224
|
+
<Show when={!props.isLoading && props.options.length > 0}>
|
|
225
|
+
<ul
|
|
226
|
+
class="mcp-autocomplete-options"
|
|
227
|
+
style={{ margin: '0', padding: '4px 0', 'list-style': 'none' }}
|
|
228
|
+
>
|
|
229
|
+
<For each={props.options}>
|
|
230
|
+
{(option, index) => {
|
|
231
|
+
const isSelected = () => index() === props.selectedIndex
|
|
232
|
+
const isDisabled = () => option.disabled
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<li
|
|
236
|
+
role="option"
|
|
237
|
+
aria-selected={isSelected()}
|
|
238
|
+
aria-disabled={isDisabled()}
|
|
239
|
+
class={`mcp-autocomplete-option ${isSelected() ? 'mcp-autocomplete-option-selected' : ''} ${isDisabled() ? 'mcp-autocomplete-option-disabled' : ''}`}
|
|
240
|
+
style={{
|
|
241
|
+
padding: '8px 16px',
|
|
242
|
+
cursor: isDisabled() ? 'not-allowed' : 'pointer',
|
|
243
|
+
'background-color': isSelected() ? '#eff6ff' : 'transparent',
|
|
244
|
+
color: isDisabled() ? '#9ca3af' : '#374151',
|
|
245
|
+
'font-size': '0.875rem',
|
|
246
|
+
transition: 'background-color 150ms ease'
|
|
247
|
+
}}
|
|
248
|
+
onClick={() => {
|
|
249
|
+
if (!isDisabled()) {
|
|
250
|
+
props.onSelect(option)
|
|
251
|
+
}
|
|
252
|
+
}}
|
|
253
|
+
onMouseEnter={() => {
|
|
254
|
+
if (!isDisabled()) {
|
|
255
|
+
props.onHover?.(index())
|
|
256
|
+
}
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
<Show
|
|
260
|
+
when={props.renderOption}
|
|
261
|
+
fallback={
|
|
262
|
+
<DefaultOptionRenderer
|
|
263
|
+
option={option}
|
|
264
|
+
isSelected={isSelected()}
|
|
265
|
+
highlightMatch={props.highlightMatch}
|
|
266
|
+
/>
|
|
267
|
+
}
|
|
268
|
+
>
|
|
269
|
+
{props.renderOption!(option, isSelected())}
|
|
270
|
+
</Show>
|
|
271
|
+
</li>
|
|
272
|
+
)
|
|
273
|
+
}}
|
|
274
|
+
</For>
|
|
275
|
+
</ul>
|
|
276
|
+
</Show>
|
|
277
|
+
|
|
278
|
+
{/* Footer hint */}
|
|
279
|
+
<Show when={!props.isLoading && props.options.length > 0}>
|
|
280
|
+
<div
|
|
281
|
+
class="mcp-autocomplete-footer"
|
|
282
|
+
style={{
|
|
283
|
+
padding: '6px 12px',
|
|
284
|
+
'border-top': '1px solid #e5e7eb',
|
|
285
|
+
'background-color': '#f9fafb',
|
|
286
|
+
'font-size': '0.75rem',
|
|
287
|
+
color: '#6b7280'
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
<kbd style={{
|
|
291
|
+
'background-color': '#e5e7eb',
|
|
292
|
+
padding: '1px 4px',
|
|
293
|
+
'border-radius': '2px',
|
|
294
|
+
'font-family': 'inherit',
|
|
295
|
+
'font-size': '0.7rem'
|
|
296
|
+
}}>↑</kbd>
|
|
297
|
+
{' '}
|
|
298
|
+
<kbd style={{
|
|
299
|
+
'background-color': '#e5e7eb',
|
|
300
|
+
padding: '1px 4px',
|
|
301
|
+
'border-radius': '2px',
|
|
302
|
+
'font-family': 'inherit',
|
|
303
|
+
'font-size': '0.7rem'
|
|
304
|
+
}}>↓</kbd>
|
|
305
|
+
{' to navigate, '}
|
|
306
|
+
<kbd style={{
|
|
307
|
+
'background-color': '#e5e7eb',
|
|
308
|
+
padding: '1px 4px',
|
|
309
|
+
'border-radius': '2px',
|
|
310
|
+
'font-family': 'inherit',
|
|
311
|
+
'font-size': '0.7rem'
|
|
312
|
+
}}>Enter</kbd>
|
|
313
|
+
{' to select, '}
|
|
314
|
+
<kbd style={{
|
|
315
|
+
'background-color': '#e5e7eb',
|
|
316
|
+
padding: '1px 4px',
|
|
317
|
+
'border-radius': '2px',
|
|
318
|
+
'font-family': 'inherit',
|
|
319
|
+
'font-size': '0.7rem'
|
|
320
|
+
}}>Esc</kbd>
|
|
321
|
+
{' to dismiss'}
|
|
322
|
+
</div>
|
|
323
|
+
</Show>
|
|
324
|
+
</div>
|
|
325
|
+
</Show>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export default AutocompleteDropdown
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutocompleteFormField Component
|
|
3
|
+
* Form field with integrated autocomplete support
|
|
4
|
+
*
|
|
5
|
+
* Sprint Autocomplete Feature
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Component, Show, createSignal, createMemo, Accessor, createEffect, on } from 'solid-js'
|
|
9
|
+
import type { FormFieldParams, FieldAutocompleteConfig, AutocompleteContext } from '../types'
|
|
10
|
+
import { useConditionalField } from '../hooks/useConditionalField'
|
|
11
|
+
import { useAutocomplete } from '../hooks/useAutocomplete'
|
|
12
|
+
import { useAutocompleteContextSafe } from '../context/AutocompleteContext'
|
|
13
|
+
import { GhostText } from './GhostText'
|
|
14
|
+
import { AutocompleteDropdown } from './AutocompleteDropdown'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extended FormFieldParams with autocomplete config
|
|
18
|
+
*/
|
|
19
|
+
export interface AutocompleteFormFieldParams extends FormFieldParams {
|
|
20
|
+
/**
|
|
21
|
+
* Autocomplete configuration for this field
|
|
22
|
+
*/
|
|
23
|
+
autocomplete?: FieldAutocompleteConfig
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Props for AutocompleteFormField
|
|
28
|
+
*/
|
|
29
|
+
export interface AutocompleteFormFieldProps {
|
|
30
|
+
/**
|
|
31
|
+
* Field configuration
|
|
32
|
+
*/
|
|
33
|
+
field: AutocompleteFormFieldParams
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Current field value
|
|
37
|
+
*/
|
|
38
|
+
value: any
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Error message
|
|
42
|
+
*/
|
|
43
|
+
error?: string
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Change handler
|
|
47
|
+
*/
|
|
48
|
+
onChange: (value: any) => void
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether field is disabled
|
|
52
|
+
*/
|
|
53
|
+
disabled?: boolean
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Form data accessor for conditional visibility and context
|
|
57
|
+
*/
|
|
58
|
+
formData?: Accessor<Record<string, any>>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* AutocompleteFormField Component
|
|
63
|
+
*/
|
|
64
|
+
export const AutocompleteFormField: Component<AutocompleteFormFieldProps> = (props) => {
|
|
65
|
+
// Check if autocomplete context is available
|
|
66
|
+
const autocompleteCtx = useAutocompleteContextSafe()
|
|
67
|
+
|
|
68
|
+
// Conditional visibility
|
|
69
|
+
const { isVisible } = useConditionalField({
|
|
70
|
+
condition: props.field.showWhen,
|
|
71
|
+
formData: props.formData || (() => ({}))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Local input value for autocomplete (may differ during suggestion)
|
|
75
|
+
const [localValue, setLocalValue] = createSignal(String(props.value || ''))
|
|
76
|
+
|
|
77
|
+
// Sync external value changes
|
|
78
|
+
createEffect(on(() => props.value, (newValue) => {
|
|
79
|
+
setLocalValue(String(newValue || ''))
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
// Build autocomplete context
|
|
83
|
+
const autocompleteContext = createMemo((): AutocompleteContext | undefined => {
|
|
84
|
+
const formData = props.formData?.()
|
|
85
|
+
const config = props.field.autocomplete
|
|
86
|
+
|
|
87
|
+
if (!config?.contextFields?.length && !formData) {
|
|
88
|
+
return { fieldName: props.field.name }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const contextData: Record<string, any> = {}
|
|
92
|
+
if (config?.contextFields && formData) {
|
|
93
|
+
config.contextFields.forEach(field => {
|
|
94
|
+
if (formData[field] !== undefined) {
|
|
95
|
+
contextData[field] = formData[field]
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
fieldName: props.field.name,
|
|
102
|
+
formData: contextData
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Initialize autocomplete hook
|
|
107
|
+
const autocomplete = useAutocomplete({
|
|
108
|
+
inputValue: localValue,
|
|
109
|
+
pluginId: props.field.autocomplete?.plugin,
|
|
110
|
+
fieldConfig: props.field.autocomplete,
|
|
111
|
+
context: () => autocompleteContext() || { fieldName: props.field.name },
|
|
112
|
+
enabled: !!(props.field.autocomplete?.enabled && autocompleteCtx),
|
|
113
|
+
minChars: props.field.autocomplete?.minChars,
|
|
114
|
+
debounceMs: props.field.autocomplete?.debounceMs,
|
|
115
|
+
onInputChange: (value) => {
|
|
116
|
+
setLocalValue(value)
|
|
117
|
+
props.onChange(value)
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// Handle input change
|
|
122
|
+
const handleInput = (value: string) => {
|
|
123
|
+
setLocalValue(value)
|
|
124
|
+
props.onChange(value)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Handle key down
|
|
128
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
129
|
+
if (autocomplete.handleKeyDown(e)) {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Base input class
|
|
135
|
+
const baseInputClass = () => `
|
|
136
|
+
w-full px-3 py-2 border rounded-md
|
|
137
|
+
focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
|
138
|
+
disabled:bg-gray-100 disabled:cursor-not-allowed
|
|
139
|
+
${props.error
|
|
140
|
+
? 'border-red-500 focus:ring-red-500'
|
|
141
|
+
: 'border-gray-300 dark:border-gray-600'}
|
|
142
|
+
dark:bg-gray-700 dark:text-white
|
|
143
|
+
`
|
|
144
|
+
|
|
145
|
+
const fieldId = () => `field-${props.field.name}`
|
|
146
|
+
const errorId = () => `${props.field.name}-error`
|
|
147
|
+
|
|
148
|
+
// Check if field supports autocomplete (text-based fields only)
|
|
149
|
+
const supportsAutocomplete = createMemo(() =>
|
|
150
|
+
['text', 'email'].includes(props.field.type)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
// Whether to show autocomplete features
|
|
154
|
+
const showAutocomplete = createMemo(() =>
|
|
155
|
+
supportsAutocomplete() &&
|
|
156
|
+
props.field.autocomplete?.enabled &&
|
|
157
|
+
autocompleteCtx !== undefined
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<Show when={isVisible()}>
|
|
162
|
+
<div class="space-y-1">
|
|
163
|
+
{/* Label */}
|
|
164
|
+
<Show when={props.field.label}>
|
|
165
|
+
<label
|
|
166
|
+
for={fieldId()}
|
|
167
|
+
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
168
|
+
>
|
|
169
|
+
{props.field.label}
|
|
170
|
+
<Show when={props.field.required}>
|
|
171
|
+
<span class="text-red-500 ml-1" aria-hidden="true">*</span>
|
|
172
|
+
</Show>
|
|
173
|
+
</label>
|
|
174
|
+
</Show>
|
|
175
|
+
|
|
176
|
+
{/* Input with autocomplete */}
|
|
177
|
+
<div class="relative">
|
|
178
|
+
<Show
|
|
179
|
+
when={showAutocomplete()}
|
|
180
|
+
fallback={
|
|
181
|
+
/* Standard input without autocomplete */
|
|
182
|
+
<input
|
|
183
|
+
id={fieldId()}
|
|
184
|
+
type={props.field.type}
|
|
185
|
+
name={props.field.name}
|
|
186
|
+
value={props.value || ''}
|
|
187
|
+
onInput={(e) => props.onChange(e.currentTarget.value)}
|
|
188
|
+
placeholder={props.field.placeholder}
|
|
189
|
+
disabled={props.disabled}
|
|
190
|
+
required={props.field.required}
|
|
191
|
+
minLength={props.field.minLength}
|
|
192
|
+
maxLength={props.field.maxLength}
|
|
193
|
+
pattern={props.field.pattern}
|
|
194
|
+
aria-invalid={!!props.error}
|
|
195
|
+
aria-describedby={props.error ? errorId() : undefined}
|
|
196
|
+
class={baseInputClass()}
|
|
197
|
+
/>
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
{/* Autocomplete-enabled input */}
|
|
201
|
+
<div class="relative">
|
|
202
|
+
{/* Ghost text overlay (for completion type) */}
|
|
203
|
+
<Show when={autocomplete.resultType() === 'completion'}>
|
|
204
|
+
<GhostText
|
|
205
|
+
inputValue={localValue()}
|
|
206
|
+
ghostText={autocomplete.ghostText()}
|
|
207
|
+
visible={autocomplete.isOpen()}
|
|
208
|
+
hintText={autocomplete.ghostText() ? 'Tab to accept' : undefined}
|
|
209
|
+
isLoading={autocomplete.isLoading()}
|
|
210
|
+
/>
|
|
211
|
+
</Show>
|
|
212
|
+
|
|
213
|
+
<input
|
|
214
|
+
id={fieldId()}
|
|
215
|
+
type={props.field.type}
|
|
216
|
+
name={props.field.name}
|
|
217
|
+
value={localValue()}
|
|
218
|
+
onInput={(e) => handleInput(e.currentTarget.value)}
|
|
219
|
+
onKeyDown={handleKeyDown}
|
|
220
|
+
onBlur={() => {
|
|
221
|
+
// Delay dismiss to allow click on dropdown
|
|
222
|
+
setTimeout(() => autocomplete.dismiss(), 150)
|
|
223
|
+
}}
|
|
224
|
+
onFocus={() => {
|
|
225
|
+
if (localValue().length >= (props.field.autocomplete?.minChars || 1)) {
|
|
226
|
+
autocomplete.open()
|
|
227
|
+
}
|
|
228
|
+
}}
|
|
229
|
+
placeholder={props.field.placeholder}
|
|
230
|
+
disabled={props.disabled}
|
|
231
|
+
required={props.field.required}
|
|
232
|
+
minLength={props.field.minLength}
|
|
233
|
+
maxLength={props.field.maxLength}
|
|
234
|
+
pattern={props.field.pattern}
|
|
235
|
+
aria-invalid={!!props.error}
|
|
236
|
+
aria-describedby={props.error ? errorId() : undefined}
|
|
237
|
+
aria-autocomplete="list"
|
|
238
|
+
aria-expanded={autocomplete.isOpen()}
|
|
239
|
+
aria-haspopup="listbox"
|
|
240
|
+
class={`${baseInputClass()} ${autocomplete.resultType() === 'completion' ? 'bg-transparent' : ''}`}
|
|
241
|
+
style={{
|
|
242
|
+
position: 'relative',
|
|
243
|
+
'z-index': '2'
|
|
244
|
+
}}
|
|
245
|
+
autocomplete="off"
|
|
246
|
+
/>
|
|
247
|
+
|
|
248
|
+
{/* Dropdown (for options type) */}
|
|
249
|
+
<Show when={autocomplete.resultType() === 'options'}>
|
|
250
|
+
<AutocompleteDropdown
|
|
251
|
+
options={autocomplete.options()}
|
|
252
|
+
selectedIndex={autocomplete.selectedIndex()}
|
|
253
|
+
isOpen={autocomplete.isOpen()}
|
|
254
|
+
isLoading={autocomplete.isLoading()}
|
|
255
|
+
onSelect={(option) => {
|
|
256
|
+
handleInput(option.value)
|
|
257
|
+
autocomplete.dismiss()
|
|
258
|
+
}}
|
|
259
|
+
onHover={(_index) => {
|
|
260
|
+
// Could add hover selection here
|
|
261
|
+
}}
|
|
262
|
+
highlightMatch={localValue()}
|
|
263
|
+
loadingMessage={props.field.autocomplete?.loadingPlaceholder}
|
|
264
|
+
/>
|
|
265
|
+
</Show>
|
|
266
|
+
</div>
|
|
267
|
+
</Show>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* Help text */}
|
|
271
|
+
<Show when={props.field.helpText && !props.error}>
|
|
272
|
+
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
273
|
+
{props.field.helpText}
|
|
274
|
+
</p>
|
|
275
|
+
</Show>
|
|
276
|
+
|
|
277
|
+
{/* Error message */}
|
|
278
|
+
<Show when={props.error}>
|
|
279
|
+
<p id={errorId()} role="alert" class="text-xs text-red-600 dark:text-red-400">
|
|
280
|
+
{props.error}
|
|
281
|
+
</p>
|
|
282
|
+
</Show>
|
|
283
|
+
</div>
|
|
284
|
+
</Show>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export default AutocompleteFormField
|