@seed-ship/mcp-ui-solid 2.0.1 → 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.
Files changed (133) hide show
  1. package/dist/components/AutocompleteDropdown.cjs +201 -0
  2. package/dist/components/AutocompleteDropdown.cjs.map +1 -0
  3. package/dist/components/AutocompleteDropdown.d.ts +71 -0
  4. package/dist/components/AutocompleteDropdown.d.ts.map +1 -0
  5. package/dist/components/AutocompleteDropdown.js +201 -0
  6. package/dist/components/AutocompleteDropdown.js.map +1 -0
  7. package/dist/components/AutocompleteFormField.cjs +289 -0
  8. package/dist/components/AutocompleteFormField.cjs.map +1 -0
  9. package/dist/components/AutocompleteFormField.d.ts +52 -0
  10. package/dist/components/AutocompleteFormField.d.ts.map +1 -0
  11. package/dist/components/AutocompleteFormField.js +289 -0
  12. package/dist/components/AutocompleteFormField.js.map +1 -0
  13. package/dist/components/DraggableGridItem.cjs +133 -0
  14. package/dist/components/DraggableGridItem.cjs.map +1 -0
  15. package/dist/components/DraggableGridItem.d.ts +95 -0
  16. package/dist/components/DraggableGridItem.d.ts.map +1 -0
  17. package/dist/components/DraggableGridItem.js +133 -0
  18. package/dist/components/DraggableGridItem.js.map +1 -0
  19. package/dist/components/EditableUIResourceRenderer.cjs +203 -0
  20. package/dist/components/EditableUIResourceRenderer.cjs.map +1 -0
  21. package/dist/components/EditableUIResourceRenderer.d.ts +43 -0
  22. package/dist/components/EditableUIResourceRenderer.d.ts.map +1 -0
  23. package/dist/components/EditableUIResourceRenderer.js +203 -0
  24. package/dist/components/EditableUIResourceRenderer.js.map +1 -0
  25. package/dist/components/GhostText.cjs +105 -0
  26. package/dist/components/GhostText.cjs.map +1 -0
  27. package/dist/components/GhostText.d.ts +113 -0
  28. package/dist/components/GhostText.d.ts.map +1 -0
  29. package/dist/components/GhostText.js +105 -0
  30. package/dist/components/GhostText.js.map +1 -0
  31. package/dist/components/ResizeHandle.cjs +173 -0
  32. package/dist/components/ResizeHandle.cjs.map +1 -0
  33. package/dist/components/ResizeHandle.d.ts +50 -0
  34. package/dist/components/ResizeHandle.d.ts.map +1 -0
  35. package/dist/components/ResizeHandle.js +173 -0
  36. package/dist/components/ResizeHandle.js.map +1 -0
  37. package/dist/context/AutocompleteContext.cjs +158 -0
  38. package/dist/context/AutocompleteContext.cjs.map +1 -0
  39. package/dist/context/AutocompleteContext.d.ts +77 -0
  40. package/dist/context/AutocompleteContext.d.ts.map +1 -0
  41. package/dist/context/AutocompleteContext.js +158 -0
  42. package/dist/context/AutocompleteContext.js.map +1 -0
  43. package/dist/hooks/index.d.ts +6 -0
  44. package/dist/hooks/index.d.ts.map +1 -1
  45. package/dist/hooks/useAutocomplete.cjs +234 -0
  46. package/dist/hooks/useAutocomplete.cjs.map +1 -0
  47. package/dist/hooks/useAutocomplete.d.ts +119 -0
  48. package/dist/hooks/useAutocomplete.d.ts.map +1 -0
  49. package/dist/hooks/useAutocomplete.js +234 -0
  50. package/dist/hooks/useAutocomplete.js.map +1 -0
  51. package/dist/hooks/useDragDrop.cjs +170 -0
  52. package/dist/hooks/useDragDrop.cjs.map +1 -0
  53. package/dist/hooks/useDragDrop.d.ts +100 -0
  54. package/dist/hooks/useDragDrop.d.ts.map +1 -0
  55. package/dist/hooks/useDragDrop.js +170 -0
  56. package/dist/hooks/useDragDrop.js.map +1 -0
  57. package/dist/hooks/useResize.cjs +209 -0
  58. package/dist/hooks/useResize.cjs.map +1 -0
  59. package/dist/hooks/useResize.d.ts +87 -0
  60. package/dist/hooks/useResize.d.ts.map +1 -0
  61. package/dist/hooks/useResize.js +209 -0
  62. package/dist/hooks/useResize.js.map +1 -0
  63. package/dist/hooks.cjs +6 -0
  64. package/dist/hooks.cjs.map +1 -1
  65. package/dist/hooks.d.cts +6 -0
  66. package/dist/hooks.d.ts +6 -0
  67. package/dist/hooks.js +6 -0
  68. package/dist/hooks.js.map +1 -1
  69. package/dist/index.cjs +29 -0
  70. package/dist/index.cjs.map +1 -1
  71. package/dist/index.d.cts +18 -3
  72. package/dist/index.d.ts +18 -3
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +29 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/plugins/duckdb.cjs +192 -0
  77. package/dist/plugins/duckdb.cjs.map +1 -0
  78. package/dist/plugins/duckdb.d.ts +20 -0
  79. package/dist/plugins/duckdb.d.ts.map +1 -0
  80. package/dist/plugins/duckdb.js +170 -0
  81. package/dist/plugins/duckdb.js.map +1 -0
  82. package/dist/plugins/groq.cjs +97 -0
  83. package/dist/plugins/groq.cjs.map +1 -0
  84. package/dist/plugins/groq.d.ts +13 -0
  85. package/dist/plugins/groq.d.ts.map +1 -0
  86. package/dist/plugins/groq.js +97 -0
  87. package/dist/plugins/groq.js.map +1 -0
  88. package/dist/plugins/index.d.ts +10 -0
  89. package/dist/plugins/index.d.ts.map +1 -0
  90. package/dist/plugins/rest.cjs +92 -0
  91. package/dist/plugins/rest.cjs.map +1 -0
  92. package/dist/plugins/rest.d.ts +13 -0
  93. package/dist/plugins/rest.d.ts.map +1 -0
  94. package/dist/plugins/rest.js +92 -0
  95. package/dist/plugins/rest.js.map +1 -0
  96. package/dist/plugins/supabase.cjs +79 -0
  97. package/dist/plugins/supabase.cjs.map +1 -0
  98. package/dist/plugins/supabase.d.ts +13 -0
  99. package/dist/plugins/supabase.d.ts.map +1 -0
  100. package/dist/plugins/supabase.js +79 -0
  101. package/dist/plugins/supabase.js.map +1 -0
  102. package/dist/types/index.d.ts +430 -0
  103. package/dist/types/index.d.ts.map +1 -1
  104. package/dist/types.d.cts +430 -0
  105. package/dist/types.d.ts +430 -0
  106. package/package.json +16 -1
  107. package/src/components/AutocompleteDropdown.tsx +329 -0
  108. package/src/components/AutocompleteFormField.tsx +288 -0
  109. package/src/components/DraggableGridItem.tsx +274 -0
  110. package/src/components/EditableUIResourceRenderer.tsx +273 -0
  111. package/src/components/GhostText.tsx +262 -0
  112. package/src/components/ResizeHandle.tsx +262 -0
  113. package/src/context/AutocompleteContext.tsx +317 -0
  114. package/src/hooks/index.ts +23 -0
  115. package/src/hooks/useAutocomplete.test.ts +334 -0
  116. package/src/hooks/useAutocomplete.ts +466 -0
  117. package/src/hooks/useDragDrop.test.ts +355 -0
  118. package/src/hooks/useDragDrop.ts +379 -0
  119. package/src/hooks/useResize.test.ts +313 -0
  120. package/src/hooks/useResize.ts +372 -0
  121. package/src/index.ts +71 -0
  122. package/src/plugins/duckdb.ts +269 -0
  123. package/src/plugins/groq.ts +137 -0
  124. package/src/plugins/index.ts +14 -0
  125. package/src/plugins/rest.ts +147 -0
  126. package/src/plugins/supabase.ts +120 -0
  127. package/src/styles/autocomplete.css +356 -0
  128. package/src/styles/drag-drop.css +297 -0
  129. package/src/styles/index.css +7 -0
  130. package/src/types/index.ts +529 -0
  131. package/src/vite-env.d.ts +18 -0
  132. package/tsconfig.tsbuildinfo +1 -1
  133. package/vite.config.ts +2 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * AutocompleteContext - Context provider for autocomplete functionality
3
+ * Manages plugins and provides unified autocomplete API
4
+ *
5
+ * Sprint Autocomplete Feature
6
+ */
7
+
8
+ import {
9
+ createContext,
10
+ useContext,
11
+ ParentComponent,
12
+ Accessor,
13
+ createSignal,
14
+ createMemo,
15
+ onCleanup
16
+ } from 'solid-js'
17
+ import type {
18
+ AutocompletePlugin,
19
+ AutocompleteResult,
20
+ AutocompleteContext as AutocompleteContextData,
21
+ AutocompleteProviderConfig
22
+ } from '../types'
23
+
24
+ /**
25
+ * Cache entry for autocomplete results
26
+ */
27
+ interface CacheEntry {
28
+ result: AutocompleteResult
29
+ timestamp: number
30
+ }
31
+
32
+ /**
33
+ * Context value interface
34
+ */
35
+ export interface AutocompleteContextValue {
36
+ /**
37
+ * Get suggestions from a plugin
38
+ */
39
+ getSuggestions: (
40
+ input: string,
41
+ pluginId?: string,
42
+ context?: AutocompleteContextData
43
+ ) => Promise<AutocompleteResult>
44
+
45
+ /**
46
+ * Get registered plugins
47
+ */
48
+ plugins: Accessor<AutocompletePlugin[]>
49
+
50
+ /**
51
+ * Get default plugin ID
52
+ */
53
+ defaultPluginId: Accessor<string | undefined>
54
+
55
+ /**
56
+ * Check if a plugin is ready
57
+ */
58
+ isPluginReady: (pluginId: string) => boolean
59
+
60
+ /**
61
+ * Get plugin by ID
62
+ */
63
+ getPlugin: (pluginId: string) => AutocompletePlugin | undefined
64
+
65
+ /**
66
+ * Register a new plugin
67
+ */
68
+ registerPlugin: (plugin: AutocompletePlugin) => void
69
+
70
+ /**
71
+ * Unregister a plugin
72
+ */
73
+ unregisterPlugin: (pluginId: string) => void
74
+
75
+ /**
76
+ * Clear cache
77
+ */
78
+ clearCache: () => void
79
+
80
+ /**
81
+ * Global config
82
+ */
83
+ config: {
84
+ debounceMs: number
85
+ minChars: number
86
+ cacheTtl: number
87
+ cacheEnabled: boolean
88
+ }
89
+ }
90
+
91
+ // Create context with undefined default
92
+ const AutocompleteCtx = createContext<AutocompleteContextValue>()
93
+
94
+ /**
95
+ * Props for AutocompleteProvider
96
+ */
97
+ export interface AutocompleteProviderProps extends AutocompleteProviderConfig {
98
+ children: any
99
+ }
100
+
101
+ /**
102
+ * Generate cache key
103
+ */
104
+ function getCacheKey(input: string, pluginId: string, context?: AutocompleteContextData): string {
105
+ const contextKey = context ? JSON.stringify(context) : ''
106
+ return `${pluginId}:${input}:${contextKey}`
107
+ }
108
+
109
+ /**
110
+ * AutocompleteProvider Component
111
+ * Provides autocomplete context to child components
112
+ */
113
+ export const AutocompleteProvider: ParentComponent<AutocompleteProviderProps> = (props) => {
114
+ // Plugin registry
115
+ const [plugins, setPlugins] = createSignal<AutocompletePlugin[]>(props.plugins || [])
116
+
117
+ // Result cache
118
+ const [cache, setCache] = createSignal<Map<string, CacheEntry>>(new Map())
119
+
120
+ // Config with defaults
121
+ const config = createMemo(() => ({
122
+ debounceMs: props.debounceMs ?? 150,
123
+ minChars: props.minChars ?? 1,
124
+ cacheTtl: props.cacheTtl ?? 60000,
125
+ cacheEnabled: props.cacheEnabled ?? true
126
+ }))
127
+
128
+ // Default plugin ID
129
+ const defaultPluginId = createMemo(() => {
130
+ if (props.defaultPlugin) return props.defaultPlugin
131
+ const firstPlugin = plugins()[0]
132
+ return firstPlugin?.id
133
+ })
134
+
135
+ /**
136
+ * Get plugin by ID
137
+ */
138
+ const getPlugin = (pluginId: string): AutocompletePlugin | undefined => {
139
+ return plugins().find(p => p.id === pluginId)
140
+ }
141
+
142
+ /**
143
+ * Check if plugin is ready
144
+ */
145
+ const isPluginReady = (pluginId: string): boolean => {
146
+ const plugin = getPlugin(pluginId)
147
+ if (!plugin) return false
148
+ if (plugin.isReady) return plugin.isReady()
149
+ return true
150
+ }
151
+
152
+ /**
153
+ * Check cache for result
154
+ */
155
+ const getFromCache = (key: string): AutocompleteResult | null => {
156
+ if (!config().cacheEnabled) return null
157
+
158
+ const entry = cache().get(key)
159
+ if (!entry) return null
160
+
161
+ const age = Date.now() - entry.timestamp
162
+ if (age > config().cacheTtl) {
163
+ // Expired, remove from cache
164
+ setCache(prev => {
165
+ const next = new Map(prev)
166
+ next.delete(key)
167
+ return next
168
+ })
169
+ return null
170
+ }
171
+
172
+ return { ...entry.result, cached: true }
173
+ }
174
+
175
+ /**
176
+ * Add result to cache
177
+ */
178
+ const addToCache = (key: string, result: AutocompleteResult): void => {
179
+ if (!config().cacheEnabled) return
180
+
181
+ setCache(prev => {
182
+ const next = new Map(prev)
183
+ next.set(key, { result, timestamp: Date.now() })
184
+ return next
185
+ })
186
+ }
187
+
188
+ /**
189
+ * Get suggestions from plugin
190
+ */
191
+ const getSuggestions = async (
192
+ input: string,
193
+ pluginId?: string,
194
+ context?: AutocompleteContextData
195
+ ): Promise<AutocompleteResult> => {
196
+ const targetPluginId = pluginId || defaultPluginId()
197
+
198
+ if (!targetPluginId) {
199
+ return { type: 'options', options: [], pluginId: undefined }
200
+ }
201
+
202
+ const plugin = getPlugin(targetPluginId)
203
+ if (!plugin) {
204
+ console.warn(`[Autocomplete] Plugin not found: ${targetPluginId}`)
205
+ return { type: 'options', options: [], pluginId: targetPluginId }
206
+ }
207
+
208
+ // Check cache
209
+ const cacheKey = getCacheKey(input, targetPluginId, context)
210
+ const cached = getFromCache(cacheKey)
211
+ if (cached) {
212
+ return cached
213
+ }
214
+
215
+ try {
216
+ const result = await plugin.getSuggestions(input, context)
217
+ result.pluginId = targetPluginId
218
+
219
+ // Cache result
220
+ addToCache(cacheKey, result)
221
+
222
+ return result
223
+ } catch (error) {
224
+ console.error(`[Autocomplete] Plugin error (${targetPluginId}):`, error)
225
+ return {
226
+ type: 'options',
227
+ options: [],
228
+ pluginId: targetPluginId
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Register a new plugin
235
+ */
236
+ const registerPlugin = (plugin: AutocompletePlugin): void => {
237
+ setPlugins(prev => {
238
+ // Remove existing plugin with same ID
239
+ const filtered = prev.filter(p => p.id !== plugin.id)
240
+ return [...filtered, plugin]
241
+ })
242
+ }
243
+
244
+ /**
245
+ * Unregister a plugin
246
+ */
247
+ const unregisterPlugin = (pluginId: string): void => {
248
+ const plugin = getPlugin(pluginId)
249
+ if (plugin?.dispose) {
250
+ plugin.dispose()
251
+ }
252
+ setPlugins(prev => prev.filter(p => p.id !== pluginId))
253
+ }
254
+
255
+ /**
256
+ * Clear cache
257
+ */
258
+ const clearCache = (): void => {
259
+ setCache(new Map())
260
+ }
261
+
262
+ // Cleanup on unmount
263
+ onCleanup(() => {
264
+ // Dispose all plugins
265
+ plugins().forEach(plugin => {
266
+ if (plugin.dispose) {
267
+ try {
268
+ plugin.dispose()
269
+ } catch (e) {
270
+ console.error(`[Autocomplete] Error disposing plugin ${plugin.id}:`, e)
271
+ }
272
+ }
273
+ })
274
+ })
275
+
276
+ // Context value
277
+ const contextValue: AutocompleteContextValue = {
278
+ getSuggestions,
279
+ plugins,
280
+ defaultPluginId,
281
+ isPluginReady,
282
+ getPlugin,
283
+ registerPlugin,
284
+ unregisterPlugin,
285
+ clearCache,
286
+ config: config()
287
+ }
288
+
289
+ return (
290
+ <AutocompleteCtx.Provider value={contextValue}>
291
+ {props.children}
292
+ </AutocompleteCtx.Provider>
293
+ )
294
+ }
295
+
296
+ /**
297
+ * Hook to use autocomplete context
298
+ * @throws Error if used outside provider
299
+ */
300
+ export function useAutocompleteContext(): AutocompleteContextValue {
301
+ const context = useContext(AutocompleteCtx)
302
+ if (!context) {
303
+ throw new Error(
304
+ 'useAutocompleteContext must be used within an AutocompleteProvider'
305
+ )
306
+ }
307
+ return context
308
+ }
309
+
310
+ /**
311
+ * Safe hook that returns undefined if outside provider
312
+ */
313
+ export function useAutocompleteContextSafe(): AutocompleteContextValue | undefined {
314
+ return useContext(AutocompleteCtx)
315
+ }
316
+
317
+ export { AutocompleteCtx }
@@ -34,3 +34,26 @@ export type { UseModalReturn, UseConfirmModalReturn } from './useModal'
34
34
  // Form persistence hooks (Sprint 4)
35
35
  export { useFormPersistence } from './useFormPersistence'
36
36
  export type { UseFormPersistenceOptions, UseFormPersistenceReturn } from './useFormPersistence'
37
+
38
+ // Drag-Drop hooks (Sprint Drag-Drop)
39
+ export { useDragDrop } from './useDragDrop'
40
+ export type {
41
+ UseDragDropOptions,
42
+ UseDragDropReturn,
43
+ DragProps,
44
+ } from './useDragDrop'
45
+
46
+ export { useResize } from './useResize'
47
+ export type {
48
+ UseResizeOptions,
49
+ UseResizeReturn,
50
+ ResizeEdge,
51
+ ResizeHandleProps,
52
+ } from './useResize'
53
+
54
+ // Autocomplete hooks (Sprint Autocomplete)
55
+ export { useAutocomplete } from './useAutocomplete'
56
+ export type {
57
+ UseAutocompleteOptions,
58
+ UseAutocompleteReturn,
59
+ } from './useAutocomplete'
@@ -0,0 +1,334 @@
1
+ /**
2
+ * useAutocomplete Tests
3
+ * Sprint Autocomplete Feature
4
+ */
5
+
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
7
+ import { createSignal, createRoot } from 'solid-js'
8
+ import { useAutocomplete } from './useAutocomplete'
9
+ // Note: Full integration tests require AutocompleteProvider
10
+ // These tests focus on the hook's internal logic
11
+
12
+ describe('useAutocomplete', () => {
13
+ beforeEach(() => {
14
+ vi.useFakeTimers()
15
+ })
16
+
17
+ afterEach(() => {
18
+ vi.useRealTimers()
19
+ vi.clearAllMocks()
20
+ })
21
+
22
+ describe('initialization', () => {
23
+ it('initializes with correct default state', () => {
24
+ createRoot((dispose) => {
25
+ const [inputValue] = createSignal('')
26
+
27
+ const autocomplete = useAutocomplete({
28
+ inputValue,
29
+ enabled: true
30
+ })
31
+
32
+ expect(autocomplete.completion()).toBeNull()
33
+ expect(autocomplete.ghostText()).toBe('')
34
+ expect(autocomplete.options()).toEqual([])
35
+ expect(autocomplete.selectedIndex()).toBe(-1)
36
+ expect(autocomplete.isLoading()).toBe(false)
37
+ expect(autocomplete.error()).toBeNull()
38
+ expect(autocomplete.isOpen()).toBe(false)
39
+ expect(autocomplete.resultType()).toBeNull()
40
+
41
+ dispose()
42
+ })
43
+ })
44
+
45
+ it('does not fetch when disabled', () => {
46
+ createRoot((dispose) => {
47
+ const [inputValue, setInputValue] = createSignal('')
48
+
49
+ const autocomplete = useAutocomplete({
50
+ inputValue,
51
+ enabled: false
52
+ })
53
+
54
+ setInputValue('test')
55
+ vi.advanceTimersByTime(200)
56
+
57
+ expect(autocomplete.isLoading()).toBe(false)
58
+ expect(autocomplete.isOpen()).toBe(false)
59
+
60
+ dispose()
61
+ })
62
+ })
63
+ })
64
+
65
+ describe('ghost text computation', () => {
66
+ it('computes ghost text correctly', () => {
67
+ createRoot((dispose) => {
68
+ const [inputValue] = createSignal('hel')
69
+ const onInputChange = vi.fn()
70
+
71
+ const autocomplete = useAutocomplete({
72
+ inputValue,
73
+ enabled: true,
74
+ onInputChange
75
+ })
76
+
77
+ // Manually set completion for testing
78
+ // In real usage, this would come from the context
79
+ expect(autocomplete.ghostText()).toBe('')
80
+
81
+ dispose()
82
+ })
83
+ })
84
+ })
85
+
86
+ describe('option navigation', () => {
87
+ it('navigates options with nextOption', () => {
88
+ createRoot((dispose) => {
89
+ const [inputValue] = createSignal('test')
90
+
91
+ const autocomplete = useAutocomplete({
92
+ inputValue,
93
+ enabled: true
94
+ })
95
+
96
+ // Initially no selection
97
+ expect(autocomplete.selectedIndex()).toBe(-1)
98
+
99
+ dispose()
100
+ })
101
+ })
102
+
103
+ it('navigates options with prevOption', () => {
104
+ createRoot((dispose) => {
105
+ const [inputValue] = createSignal('test')
106
+
107
+ const autocomplete = useAutocomplete({
108
+ inputValue,
109
+ enabled: true
110
+ })
111
+
112
+ // Initially no selection
113
+ expect(autocomplete.selectedIndex()).toBe(-1)
114
+
115
+ dispose()
116
+ })
117
+ })
118
+ })
119
+
120
+ describe('keyboard handling', () => {
121
+ it('handles Tab key for completion', () => {
122
+ createRoot((dispose) => {
123
+ const [inputValue] = createSignal('test')
124
+ const onInputChange = vi.fn()
125
+
126
+ const autocomplete = useAutocomplete({
127
+ inputValue,
128
+ enabled: true,
129
+ onInputChange
130
+ })
131
+
132
+ const event = {
133
+ key: 'Tab',
134
+ preventDefault: vi.fn()
135
+ } as unknown as KeyboardEvent
136
+
137
+ // Should return false when not open
138
+ const handled = autocomplete.handleKeyDown(event)
139
+ expect(handled).toBe(false)
140
+
141
+ dispose()
142
+ })
143
+ })
144
+
145
+ it('handles Escape key to dismiss', () => {
146
+ createRoot((dispose) => {
147
+ const [inputValue] = createSignal('test')
148
+
149
+ const autocomplete = useAutocomplete({
150
+ inputValue,
151
+ enabled: true
152
+ })
153
+
154
+ const event = {
155
+ key: 'Escape',
156
+ preventDefault: vi.fn()
157
+ } as unknown as KeyboardEvent
158
+
159
+ // Should return false when not open
160
+ const handled = autocomplete.handleKeyDown(event)
161
+ expect(handled).toBe(false)
162
+
163
+ dispose()
164
+ })
165
+ })
166
+
167
+ it('handles ArrowDown for dropdown navigation', () => {
168
+ createRoot((dispose) => {
169
+ const [inputValue] = createSignal('test')
170
+
171
+ const autocomplete = useAutocomplete({
172
+ inputValue,
173
+ enabled: true
174
+ })
175
+
176
+ const event = {
177
+ key: 'ArrowDown',
178
+ preventDefault: vi.fn()
179
+ } as unknown as KeyboardEvent
180
+
181
+ // Should return false when not open
182
+ const handled = autocomplete.handleKeyDown(event)
183
+ expect(handled).toBe(false)
184
+
185
+ dispose()
186
+ })
187
+ })
188
+
189
+ it('handles ArrowUp for dropdown navigation', () => {
190
+ createRoot((dispose) => {
191
+ const [inputValue] = createSignal('test')
192
+
193
+ const autocomplete = useAutocomplete({
194
+ inputValue,
195
+ enabled: true
196
+ })
197
+
198
+ const event = {
199
+ key: 'ArrowUp',
200
+ preventDefault: vi.fn()
201
+ } as unknown as KeyboardEvent
202
+
203
+ // Should return false when not open
204
+ const handled = autocomplete.handleKeyDown(event)
205
+ expect(handled).toBe(false)
206
+
207
+ dispose()
208
+ })
209
+ })
210
+ })
211
+
212
+ describe('dismiss', () => {
213
+ it('clears all state on dismiss', () => {
214
+ createRoot((dispose) => {
215
+ const [inputValue] = createSignal('test')
216
+
217
+ const autocomplete = useAutocomplete({
218
+ inputValue,
219
+ enabled: true
220
+ })
221
+
222
+ autocomplete.dismiss()
223
+
224
+ expect(autocomplete.completion()).toBeNull()
225
+ expect(autocomplete.options()).toEqual([])
226
+ expect(autocomplete.selectedIndex()).toBe(-1)
227
+ expect(autocomplete.isOpen()).toBe(false)
228
+ expect(autocomplete.error()).toBeNull()
229
+
230
+ dispose()
231
+ })
232
+ })
233
+ })
234
+
235
+ describe('min chars', () => {
236
+ it('respects minChars option', () => {
237
+ createRoot((dispose) => {
238
+ const [inputValue, setInputValue] = createSignal('a')
239
+
240
+ const autocomplete = useAutocomplete({
241
+ inputValue,
242
+ enabled: true,
243
+ minChars: 3
244
+ })
245
+
246
+ // Should not trigger with only 1 char
247
+ setInputValue('ab')
248
+ vi.advanceTimersByTime(200)
249
+
250
+ expect(autocomplete.isLoading()).toBe(false)
251
+ expect(autocomplete.isOpen()).toBe(false)
252
+
253
+ dispose()
254
+ })
255
+ })
256
+ })
257
+
258
+ describe('debounce', () => {
259
+ it('debounces input changes', () => {
260
+ createRoot((dispose) => {
261
+ const [inputValue, setInputValue] = createSignal('')
262
+
263
+ const autocomplete = useAutocomplete({
264
+ inputValue,
265
+ enabled: true,
266
+ debounceMs: 300
267
+ })
268
+
269
+ // Rapid changes should be debounced
270
+ setInputValue('a')
271
+ vi.advanceTimersByTime(100)
272
+ setInputValue('ab')
273
+ vi.advanceTimersByTime(100)
274
+ setInputValue('abc')
275
+ vi.advanceTimersByTime(100)
276
+
277
+ // Not enough time has passed
278
+ expect(autocomplete.isLoading()).toBe(false)
279
+
280
+ // After full debounce time
281
+ vi.advanceTimersByTime(300)
282
+
283
+ // Would be loading if context was available
284
+ expect(autocomplete.isOpen()).toBe(false) // No context
285
+
286
+ dispose()
287
+ })
288
+ })
289
+ })
290
+
291
+ describe('option selection', () => {
292
+ it('calls onInputChange when selecting option', () => {
293
+ createRoot((dispose) => {
294
+ const [inputValue] = createSignal('test')
295
+ const onInputChange = vi.fn()
296
+
297
+ const autocomplete = useAutocomplete({
298
+ inputValue,
299
+ enabled: true,
300
+ onInputChange
301
+ })
302
+
303
+ autocomplete.selectOption({ value: 'selected', label: 'Selected Option' })
304
+
305
+ expect(onInputChange).toHaveBeenCalledWith('selected')
306
+
307
+ dispose()
308
+ })
309
+ })
310
+ })
311
+
312
+ describe('accept completion', () => {
313
+ it('calls onInputChange when accepting completion', () => {
314
+ createRoot((dispose) => {
315
+ const [inputValue] = createSignal('test')
316
+ const onInputChange = vi.fn()
317
+
318
+ const autocomplete = useAutocomplete({
319
+ inputValue,
320
+ enabled: true,
321
+ onInputChange
322
+ })
323
+
324
+ // Without actual completion, this should not call onChange
325
+ autocomplete.acceptCompletion()
326
+
327
+ // Since completion is null, onInputChange should not be called
328
+ expect(onInputChange).not.toHaveBeenCalled()
329
+
330
+ dispose()
331
+ })
332
+ })
333
+ })
334
+ })