@d34dman/flowdrop 0.0.43 → 0.0.44

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 (95) hide show
  1. package/README.md +8 -8
  2. package/dist/api/enhanced-client.d.ts +3 -1
  3. package/dist/api/enhanced-client.js +35 -5
  4. package/dist/components/App.svelte +68 -34
  5. package/dist/components/ConfigForm.svelte +169 -142
  6. package/dist/components/ConfigForm.svelte.d.ts +4 -2
  7. package/dist/components/ConfigPanel.svelte +42 -15
  8. package/dist/components/LogsSidebar.svelte +20 -19
  9. package/dist/components/Navbar.svelte +150 -80
  10. package/dist/components/Navbar.svelte.d.ts +8 -0
  11. package/dist/components/NodeSidebar.svelte +330 -217
  12. package/dist/components/PipelineStatus.svelte +6 -1
  13. package/dist/components/ReadOnlyDetails.svelte +14 -14
  14. package/dist/components/SchemaForm.svelte +49 -30
  15. package/dist/components/SchemaForm.svelte.d.ts +11 -1
  16. package/dist/components/SettingsModal.svelte +279 -0
  17. package/dist/components/SettingsModal.svelte.d.ts +23 -0
  18. package/dist/components/SettingsPanel.svelte +615 -0
  19. package/dist/components/SettingsPanel.svelte.d.ts +21 -0
  20. package/dist/components/ThemeToggle.svelte +186 -0
  21. package/dist/components/ThemeToggle.svelte.d.ts +14 -0
  22. package/dist/components/WorkflowEditor.svelte +110 -36
  23. package/dist/components/form/FormArray.svelte +81 -81
  24. package/dist/components/form/FormAutocomplete.svelte +1014 -0
  25. package/dist/components/form/FormAutocomplete.svelte.d.ts +25 -0
  26. package/dist/components/form/FormCheckboxGroup.svelte +16 -16
  27. package/dist/components/form/FormCodeEditor.svelte +26 -26
  28. package/dist/components/form/FormField.svelte +52 -21
  29. package/dist/components/form/FormFieldLight.svelte +19 -19
  30. package/dist/components/form/FormFieldWrapper.svelte +4 -4
  31. package/dist/components/form/FormMarkdownEditor.svelte +124 -57
  32. package/dist/components/form/FormNumberField.svelte +13 -13
  33. package/dist/components/form/FormRangeField.svelte +16 -16
  34. package/dist/components/form/FormSelect.svelte +15 -15
  35. package/dist/components/form/FormTemplateEditor.svelte +34 -34
  36. package/dist/components/form/FormTextField.svelte +13 -13
  37. package/dist/components/form/FormTextarea.svelte +13 -13
  38. package/dist/components/form/FormToggle.svelte +8 -8
  39. package/dist/components/form/index.d.ts +1 -0
  40. package/dist/components/form/index.js +1 -0
  41. package/dist/components/form/types.d.ts +133 -8
  42. package/dist/components/form/types.js +50 -1
  43. package/dist/components/interrupt/ChoicePrompt.svelte +45 -38
  44. package/dist/components/interrupt/ConfirmationPrompt.svelte +35 -35
  45. package/dist/components/interrupt/FormPrompt.svelte +27 -20
  46. package/dist/components/interrupt/InterruptBubble.svelte +50 -50
  47. package/dist/components/interrupt/TextInputPrompt.svelte +39 -32
  48. package/dist/components/layouts/MainLayout.svelte +233 -34
  49. package/dist/components/layouts/MainLayout.svelte.d.ts +12 -0
  50. package/dist/components/nodes/GatewayNode.svelte +102 -73
  51. package/dist/components/nodes/IdeaNode.svelte +53 -52
  52. package/dist/components/nodes/NotesNode.svelte +120 -88
  53. package/dist/components/nodes/SimpleNode.svelte +67 -47
  54. package/dist/components/nodes/SquareNode.svelte +86 -49
  55. package/dist/components/nodes/TerminalNode.svelte +122 -72
  56. package/dist/components/nodes/ToolNode.svelte +96 -65
  57. package/dist/components/nodes/WorkflowNode.svelte +91 -67
  58. package/dist/components/playground/ChatPanel.svelte +76 -76
  59. package/dist/components/playground/ExecutionLogs.svelte +71 -69
  60. package/dist/components/playground/InputCollector.svelte +59 -59
  61. package/dist/components/playground/MessageBubble.svelte +111 -112
  62. package/dist/components/playground/Playground.svelte +184 -138
  63. package/dist/components/playground/PlaygroundModal.svelte +18 -19
  64. package/dist/components/playground/SessionManager.svelte +68 -67
  65. package/dist/config/defaultPortConfig.js +22 -22
  66. package/dist/core/index.d.ts +2 -0
  67. package/dist/core/index.js +1 -0
  68. package/dist/form/fieldRegistry.d.ts +17 -1
  69. package/dist/form/fieldRegistry.js +18 -2
  70. package/dist/form/index.d.ts +20 -2
  71. package/dist/form/index.js +19 -1
  72. package/dist/helpers/workflowEditorHelper.js +23 -11
  73. package/dist/index.d.ts +5 -0
  74. package/dist/index.js +13 -0
  75. package/dist/services/autoSaveService.d.ts +112 -0
  76. package/dist/services/autoSaveService.js +223 -0
  77. package/dist/services/settingsService.d.ts +92 -0
  78. package/dist/services/settingsService.js +202 -0
  79. package/dist/services/toastService.d.ts +9 -0
  80. package/dist/services/toastService.js +30 -1
  81. package/dist/stores/settingsStore.d.ts +128 -0
  82. package/dist/stores/settingsStore.js +488 -0
  83. package/dist/stores/themeStore.d.ts +68 -0
  84. package/dist/stores/themeStore.js +215 -0
  85. package/dist/styles/base.css +298 -621
  86. package/dist/styles/toast.css +33 -0
  87. package/dist/styles/tokens.css +366 -0
  88. package/dist/types/index.d.ts +78 -0
  89. package/dist/types/index.js +2 -0
  90. package/dist/types/playground.d.ts +12 -0
  91. package/dist/types/settings.d.ts +185 -0
  92. package/dist/types/settings.js +101 -0
  93. package/dist/utils/colors.d.ts +100 -7
  94. package/dist/utils/colors.js +228 -67
  95. package/package.json +3 -3
@@ -0,0 +1,1014 @@
1
+ <!--
2
+ FormAutocomplete Component
3
+ Text input with autocomplete suggestions fetched from a callback URL
4
+
5
+ Features:
6
+ - Single or multiple selection support
7
+ - Selected values displayed as removable tags with light blue background
8
+ - Debounced fetching on input
9
+ - Optional fetch on focus
10
+ - Loading state indicator
11
+ - Keyboard navigation (arrow keys, enter, escape, backspace to remove)
12
+ - Click-away to close dropdown
13
+ - Response mapping with labelField/valueField
14
+ - Error handling with retry
15
+ - Full accessibility support (ARIA combobox pattern)
16
+
17
+ Usage:
18
+ - For single selection: autocomplete.multiple = false (default)
19
+ - For multiple selection: autocomplete.multiple = true
20
+ - Selected values appear as tags with "x" button to remove
21
+ -->
22
+
23
+ <script lang="ts">
24
+ import { getContext, onMount } from 'svelte';
25
+ import Icon from '@iconify/svelte';
26
+ import type { AutocompleteConfig, AuthProvider } from '../../types/index.js';
27
+ import type { FieldOption } from './types.js';
28
+
29
+ /**
30
+ * Props interface for FormAutocomplete component
31
+ */
32
+ interface Props {
33
+ /** Field identifier */
34
+ id: string;
35
+ /** Current selected value (string for single, string[] for multiple) */
36
+ value: string | string[];
37
+ /** Autocomplete configuration */
38
+ autocomplete: AutocompleteConfig;
39
+ /** Whether the field is required */
40
+ required?: boolean;
41
+ /** Placeholder text */
42
+ placeholder?: string;
43
+ /** Whether the field is disabled */
44
+ disabled?: boolean;
45
+ /** ARIA description ID */
46
+ ariaDescribedBy?: string;
47
+ /** Callback when value changes */
48
+ onChange: (value: string | string[]) => void;
49
+ }
50
+
51
+ let {
52
+ id,
53
+ value = '',
54
+ autocomplete,
55
+ required = false,
56
+ placeholder = '',
57
+ disabled = false,
58
+ ariaDescribedBy,
59
+ onChange
60
+ }: Props = $props();
61
+
62
+ // Get AuthProvider from context (set by SchemaForm or parent)
63
+ const authProvider = getContext<AuthProvider | undefined>('flowdrop:authProvider');
64
+ const baseUrl = getContext<string | undefined>('flowdrop:baseUrl') ?? '';
65
+
66
+ // Configuration with defaults
67
+ const queryParam = $derived(autocomplete.queryParam ?? 'q');
68
+ const minChars = $derived(autocomplete.minChars ?? 0);
69
+ const debounceMs = $derived(autocomplete.debounceMs ?? 300);
70
+ const fetchOnFocus = $derived(autocomplete.fetchOnFocus ?? false);
71
+ const labelField = $derived(autocomplete.labelField ?? 'label');
72
+ const valueField = $derived(autocomplete.valueField ?? 'value');
73
+ const allowFreeText = $derived(autocomplete.allowFreeText ?? false);
74
+ const multiple = $derived(autocomplete.multiple ?? false);
75
+
76
+ // Component state
77
+ let inputElement: HTMLInputElement | undefined = $state(undefined);
78
+ let containerElement: HTMLDivElement | undefined = $state(undefined);
79
+ let popoverElement: HTMLDivElement | undefined = $state(undefined);
80
+ let inputValue = $state('');
81
+ let suggestions = $state<FieldOption[]>([]);
82
+ let isOpen = $state(false);
83
+ let isLoading = $state(false);
84
+ let error = $state<string | null>(null);
85
+ let highlightedIndex = $state(-1);
86
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
87
+ let abortController: AbortController | null = null;
88
+
89
+ // Popover positioning style
90
+ let popoverStyle = $state('');
91
+
92
+ // Cache of value-to-label mappings for selected items
93
+ let labelCache = $state<Map<string, string>>(new Map());
94
+
95
+ // Generate unique IDs for accessibility
96
+ const listboxId = `${id}-listbox`;
97
+ const getOptionId = (index: number): string => `${id}-option-${index}`;
98
+
99
+ /**
100
+ * Get the selected values as an array (normalizes single/multiple)
101
+ */
102
+ const selectedValues = $derived<string[]>(
103
+ multiple
104
+ ? Array.isArray(value)
105
+ ? value
106
+ : value
107
+ ? [String(value)]
108
+ : []
109
+ : value
110
+ ? [String(value)]
111
+ : []
112
+ );
113
+
114
+ /**
115
+ * Check if a value is selected
116
+ */
117
+ function isSelected(optionValue: string): boolean {
118
+ return selectedValues.includes(optionValue);
119
+ }
120
+
121
+ /**
122
+ * Get display label for a selected value
123
+ */
124
+ function getDisplayLabel(val: string): string {
125
+ // Check cache first
126
+ if (labelCache.has(val)) {
127
+ return labelCache.get(val) ?? val;
128
+ }
129
+ // Check current suggestions
130
+ const match = suggestions.find((s) => s.value === val);
131
+ if (match) {
132
+ labelCache.set(val, match.label);
133
+ return match.label;
134
+ }
135
+ // Return value as fallback
136
+ return val;
137
+ }
138
+
139
+ /**
140
+ * Build the full URL for fetching suggestions
141
+ * @param query - The search query
142
+ * @returns Full URL with query parameter
143
+ */
144
+ function buildUrl(query: string): string {
145
+ const url = autocomplete.url.startsWith('http')
146
+ ? autocomplete.url
147
+ : `${baseUrl}${autocomplete.url}`;
148
+
149
+ const separator = url.includes('?') ? '&' : '?';
150
+ return `${url}${separator}${encodeURIComponent(queryParam)}=${encodeURIComponent(query)}`;
151
+ }
152
+
153
+ /**
154
+ * Map API response to FieldOption array
155
+ * @param data - Response data from API
156
+ * @returns Array of FieldOption objects
157
+ */
158
+ function mapResponse(data: unknown): FieldOption[] {
159
+ if (!Array.isArray(data)) {
160
+ console.warn('[FormAutocomplete] Response is not an array:', data);
161
+ return [];
162
+ }
163
+
164
+ return data.map((item: Record<string, unknown>) => ({
165
+ label: String(item[labelField] ?? item[valueField] ?? ''),
166
+ value: String(item[valueField] ?? '')
167
+ }));
168
+ }
169
+
170
+ /**
171
+ * Fetch suggestions from the callback URL
172
+ * @param query - The search query
173
+ */
174
+ async function fetchSuggestions(query: string): Promise<void> {
175
+ // Cancel any pending request
176
+ if (abortController) {
177
+ abortController.abort();
178
+ }
179
+
180
+ // Check minimum characters requirement
181
+ if (query.length < minChars && query.length > 0) {
182
+ suggestions = [];
183
+ return;
184
+ }
185
+
186
+ isLoading = true;
187
+ error = null;
188
+ abortController = new AbortController();
189
+
190
+ try {
191
+ // Build headers with authentication
192
+ const headers: Record<string, string> = {
193
+ Accept: 'application/json',
194
+ 'Content-Type': 'application/json'
195
+ };
196
+
197
+ // Add auth headers if provider is available
198
+ if (authProvider) {
199
+ const authHeaders = await authProvider.getAuthHeaders();
200
+ Object.assign(headers, authHeaders);
201
+ }
202
+
203
+ // Fetch with timeout
204
+ const timeoutId = setTimeout(() => {
205
+ abortController?.abort();
206
+ }, 5000);
207
+
208
+ const response = await fetch(buildUrl(query), {
209
+ method: 'GET',
210
+ headers,
211
+ signal: abortController.signal
212
+ });
213
+
214
+ clearTimeout(timeoutId);
215
+
216
+ if (!response.ok) {
217
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
218
+ }
219
+
220
+ const data = await response.json();
221
+ const mapped = mapResponse(data);
222
+
223
+ // Update label cache with fetched suggestions
224
+ mapped.forEach((opt) => {
225
+ labelCache.set(opt.value, opt.label);
226
+ });
227
+
228
+ suggestions = mapped;
229
+ highlightedIndex = -1;
230
+ } catch (err) {
231
+ if (err instanceof Error && err.name === 'AbortError') {
232
+ // Request was cancelled, ignore
233
+ return;
234
+ }
235
+ console.error('[FormAutocomplete] Fetch error:', err);
236
+ error = err instanceof Error ? err.message : 'Failed to fetch suggestions';
237
+ suggestions = [];
238
+ } finally {
239
+ isLoading = false;
240
+ abortController = null;
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Debounced fetch for input changes
246
+ * @param query - The search query
247
+ */
248
+ function debouncedFetch(query: string): void {
249
+ if (debounceTimer) {
250
+ clearTimeout(debounceTimer);
251
+ }
252
+
253
+ debounceTimer = setTimeout(() => {
254
+ fetchSuggestions(query);
255
+ }, debounceMs);
256
+ }
257
+
258
+ /**
259
+ * Handle input value changes
260
+ * @param event - Input event
261
+ */
262
+ function handleInput(event: Event): void {
263
+ const target = event.currentTarget as HTMLInputElement;
264
+ inputValue = target.value;
265
+
266
+ // Open dropdown
267
+ showDropdown();
268
+
269
+ // If allowFreeText and single mode, update the value immediately
270
+ if (allowFreeText && !multiple) {
271
+ onChange(inputValue);
272
+ }
273
+
274
+ // Fetch suggestions with debounce
275
+ debouncedFetch(inputValue);
276
+ }
277
+
278
+ /**
279
+ * Handle input focus
280
+ */
281
+ function handleFocus(): void {
282
+ if (fetchOnFocus && suggestions.length === 0 && !isLoading) {
283
+ fetchSuggestions(inputValue);
284
+ }
285
+ showDropdown();
286
+ }
287
+
288
+ /**
289
+ * Handle input blur
290
+ * Delayed to allow click events on options to fire first
291
+ */
292
+ function handleBlur(): void {
293
+ setTimeout(() => {
294
+ hideDropdown();
295
+
296
+ // If not allowFreeText and single mode, validate the input
297
+ if (!allowFreeText && !multiple && inputValue !== '') {
298
+ const currentVal = selectedValues;
299
+ const matchingSuggestion = suggestions.find(
300
+ (s) => s.value === currentVal[0] || s.label.toLowerCase() === inputValue.toLowerCase()
301
+ );
302
+ if (!matchingSuggestion && currentVal.length === 0) {
303
+ inputValue = '';
304
+ }
305
+ }
306
+ }, 200);
307
+ }
308
+
309
+ /**
310
+ * Handle option selection
311
+ * @param option - Selected option
312
+ */
313
+ function selectOption(option: FieldOption): void {
314
+ // Update label cache
315
+ labelCache.set(option.value, option.label);
316
+
317
+ if (multiple) {
318
+ const current = selectedValues;
319
+ if (current.includes(option.value)) {
320
+ // Remove if already selected
321
+ const newValues = current.filter((v) => v !== option.value);
322
+ onChange(newValues);
323
+ } else {
324
+ // Add to selection
325
+ const newValues = [...current, option.value];
326
+ onChange(newValues);
327
+ }
328
+ // Clear input and keep dropdown open for more selections
329
+ inputValue = '';
330
+ inputElement?.focus();
331
+ } else {
332
+ // Single selection mode
333
+ inputValue = '';
334
+ onChange(option.value);
335
+ hideDropdown();
336
+ }
337
+ highlightedIndex = -1;
338
+ }
339
+
340
+ /**
341
+ * Remove a selected tag
342
+ * @param valueToRemove - The value to remove
343
+ */
344
+ function removeTag(valueToRemove: string): void {
345
+ if (disabled) return;
346
+
347
+ if (multiple) {
348
+ const current = selectedValues;
349
+ const newValues = current.filter((v) => v !== valueToRemove);
350
+ onChange(newValues);
351
+ } else {
352
+ onChange('');
353
+ }
354
+ inputElement?.focus();
355
+ }
356
+
357
+ /**
358
+ * Handle keyboard navigation
359
+ * @param event - Keyboard event
360
+ */
361
+ function handleKeydown(event: KeyboardEvent): void {
362
+ // Handle backspace to remove last tag in multiple mode
363
+ if (event.key === 'Backspace' && inputValue === '' && selectedValues.length > 0) {
364
+ event.preventDefault();
365
+ const current = selectedValues;
366
+ if (multiple) {
367
+ const newValues = current.slice(0, -1);
368
+ onChange(newValues);
369
+ } else {
370
+ onChange('');
371
+ }
372
+ return;
373
+ }
374
+
375
+ if (!isOpen && event.key !== 'ArrowDown' && event.key !== 'Enter') {
376
+ return;
377
+ }
378
+
379
+ switch (event.key) {
380
+ case 'ArrowDown':
381
+ event.preventDefault();
382
+ if (!isOpen) {
383
+ showDropdown();
384
+ if (fetchOnFocus && suggestions.length === 0) {
385
+ fetchSuggestions(inputValue);
386
+ }
387
+ } else {
388
+ highlightedIndex = Math.min(highlightedIndex + 1, suggestions.length - 1);
389
+ }
390
+ break;
391
+
392
+ case 'ArrowUp':
393
+ event.preventDefault();
394
+ highlightedIndex = Math.max(highlightedIndex - 1, -1);
395
+ break;
396
+
397
+ case 'Enter':
398
+ event.preventDefault();
399
+ if (highlightedIndex >= 0 && highlightedIndex < suggestions.length) {
400
+ selectOption(suggestions[highlightedIndex]);
401
+ } else if (allowFreeText && inputValue !== '') {
402
+ if (multiple) {
403
+ const current = selectedValues;
404
+ if (!current.includes(inputValue)) {
405
+ onChange([...current, inputValue]);
406
+ }
407
+ inputValue = '';
408
+ } else {
409
+ onChange(inputValue);
410
+ hideDropdown();
411
+ }
412
+ }
413
+ break;
414
+
415
+ case 'Escape':
416
+ event.preventDefault();
417
+ hideDropdown();
418
+ highlightedIndex = -1;
419
+ break;
420
+
421
+ case 'Tab':
422
+ // Allow tab to close dropdown naturally
423
+ hideDropdown();
424
+ break;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Retry failed fetch
430
+ */
431
+ function handleRetry(): void {
432
+ error = null;
433
+ fetchSuggestions(inputValue);
434
+ }
435
+
436
+ /**
437
+ * Clear all selections
438
+ */
439
+ function handleClearAll(): void {
440
+ inputValue = '';
441
+ onChange(multiple ? [] : '');
442
+ suggestions = [];
443
+ inputElement?.focus();
444
+ }
445
+
446
+ /**
447
+ * Sync label cache when value prop changes externally
448
+ */
449
+ $effect(() => {
450
+ // When value changes, try to find labels from suggestions
451
+ const vals = selectedValues;
452
+ vals.forEach((val) => {
453
+ if (!labelCache.has(val)) {
454
+ const match = suggestions.find((s) => s.value === val);
455
+ if (match) {
456
+ labelCache.set(val, match.label);
457
+ }
458
+ }
459
+ });
460
+ });
461
+
462
+ /**
463
+ * Calculate popover position relative to viewport
464
+ * Popover API renders in top layer, bypassing all stacking contexts
465
+ */
466
+ function updatePopoverPosition(): void {
467
+ if (!containerElement) return;
468
+
469
+ const rect = containerElement.getBoundingClientRect();
470
+ const viewportHeight = window.innerHeight;
471
+ const maxDropdownHeight = 240; // 15rem in pixels approximately
472
+ const spaceBelow = viewportHeight - rect.bottom;
473
+ const spaceAbove = rect.top;
474
+
475
+ const left = rect.left;
476
+ const width = rect.width;
477
+
478
+ if (spaceBelow < maxDropdownHeight && spaceAbove > spaceBelow) {
479
+ // Position above the input
480
+ const bottom = viewportHeight - rect.top + 4;
481
+ const maxHeight = Math.min(spaceAbove - 8, maxDropdownHeight);
482
+ popoverStyle = `bottom: ${bottom}px; left: ${left}px; width: ${width}px; max-height: ${maxHeight}px;`;
483
+ } else {
484
+ // Position below the input (default)
485
+ const top = rect.bottom + 4;
486
+ const maxHeight = Math.min(spaceBelow - 8, maxDropdownHeight);
487
+ popoverStyle = `top: ${top}px; left: ${left}px; width: ${width}px; max-height: ${maxHeight}px;`;
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Show the popover dropdown
493
+ */
494
+ function showDropdown(): void {
495
+ if (!popoverElement || disabled) return;
496
+
497
+ updatePopoverPosition();
498
+
499
+ try {
500
+ popoverElement.showPopover();
501
+ isOpen = true;
502
+ } catch {
503
+ // Fallback for browsers without popover support - just set isOpen
504
+ isOpen = true;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Hide the popover dropdown
510
+ */
511
+ function hideDropdown(): void {
512
+ if (!popoverElement) return;
513
+
514
+ try {
515
+ popoverElement.hidePopover();
516
+ } catch {
517
+ // Fallback for browsers without popover support
518
+ }
519
+ isOpen = false;
520
+ }
521
+
522
+ /**
523
+ * Effect to update popover position on scroll/resize when open
524
+ */
525
+ $effect(() => {
526
+ if (isOpen && containerElement) {
527
+ const handlePositionUpdate = (): void => {
528
+ updatePopoverPosition();
529
+ };
530
+
531
+ window.addEventListener('scroll', handlePositionUpdate, true);
532
+ window.addEventListener('resize', handlePositionUpdate);
533
+
534
+ return () => {
535
+ window.removeEventListener('scroll', handlePositionUpdate, true);
536
+ window.removeEventListener('resize', handlePositionUpdate);
537
+ };
538
+ }
539
+ });
540
+
541
+ /**
542
+ * Cleanup on unmount
543
+ */
544
+ onMount(() => {
545
+ return () => {
546
+ // Cleanup debounce timer
547
+ if (debounceTimer) {
548
+ clearTimeout(debounceTimer);
549
+ }
550
+ // Cleanup abort controller
551
+ if (abortController) {
552
+ abortController.abort();
553
+ }
554
+ };
555
+ });
556
+ </script>
557
+
558
+ <div
559
+ bind:this={containerElement}
560
+ class="form-autocomplete"
561
+ class:form-autocomplete--disabled={disabled}
562
+ class:form-autocomplete--multiple={multiple}
563
+ class:form-autocomplete--has-value={selectedValues.length > 0}
564
+ >
565
+ <!-- Main input container styled like a textfield/textarea -->
566
+ <div
567
+ class="form-autocomplete__field"
568
+ class:form-autocomplete__field--focused={isOpen}
569
+ onclick={() => inputElement?.focus()}
570
+ onkeydown={() => {}}
571
+ role="presentation"
572
+ >
573
+ <!-- Selected tags -->
574
+ {#if selectedValues.length > 0}
575
+ <div class="form-autocomplete__tags">
576
+ {#each selectedValues as selectedVal (selectedVal)}
577
+ <span class="form-autocomplete__tag">
578
+ <span class="form-autocomplete__tag-label">{getDisplayLabel(selectedVal)}</span>
579
+ {#if !disabled}
580
+ <button
581
+ type="button"
582
+ class="form-autocomplete__tag-remove"
583
+ aria-label={`Remove ${getDisplayLabel(selectedVal)}`}
584
+ onclick={(e) => {
585
+ e.stopPropagation();
586
+ removeTag(selectedVal);
587
+ }}
588
+ tabindex={-1}
589
+ >
590
+ <Icon icon="heroicons:x-mark" />
591
+ </button>
592
+ {/if}
593
+ </span>
594
+ {/each}
595
+ </div>
596
+ {/if}
597
+
598
+ <!-- Input area -->
599
+ <div class="form-autocomplete__input-area">
600
+ <input
601
+ bind:this={inputElement}
602
+ type="text"
603
+ {id}
604
+ class="form-autocomplete__input"
605
+ value={inputValue}
606
+ placeholder={selectedValues.length === 0 ? placeholder : ''}
607
+ {disabled}
608
+ aria-required={required}
609
+ aria-describedby={ariaDescribedBy}
610
+ role="combobox"
611
+ aria-expanded={isOpen}
612
+ aria-controls={listboxId}
613
+ aria-activedescendant={highlightedIndex >= 0 ? getOptionId(highlightedIndex) : undefined}
614
+ aria-autocomplete="list"
615
+ autocomplete="off"
616
+ oninput={handleInput}
617
+ onfocus={handleFocus}
618
+ onblur={handleBlur}
619
+ onkeydown={handleKeydown}
620
+ />
621
+ </div>
622
+
623
+ <!-- Status icons -->
624
+ <div class="form-autocomplete__icons">
625
+ {#if isLoading}
626
+ <span class="form-autocomplete__spinner" aria-label="Loading suggestions">
627
+ <Icon icon="heroicons:arrow-path" />
628
+ </span>
629
+ {:else if selectedValues.length > 0 && !disabled}
630
+ <button
631
+ type="button"
632
+ class="form-autocomplete__clear"
633
+ aria-label="Clear all selections"
634
+ onclick={(e) => {
635
+ e.stopPropagation();
636
+ handleClearAll();
637
+ }}
638
+ tabindex={-1}
639
+ >
640
+ <Icon icon="heroicons:x-mark" />
641
+ </button>
642
+ {:else}
643
+ <span class="form-autocomplete__chevron" aria-hidden="true">
644
+ <Icon icon="heroicons:chevron-down" />
645
+ </span>
646
+ {/if}
647
+ </div>
648
+ </div>
649
+
650
+ <!-- Dropdown popover (uses Popover API to render in top layer, bypassing stacking contexts) -->
651
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
652
+ <div
653
+ bind:this={popoverElement}
654
+ id={listboxId}
655
+ class="form-autocomplete__popover"
656
+ popover="manual"
657
+ role="presentation"
658
+ style={popoverStyle}
659
+ onmousedown={(e) => e.preventDefault()}
660
+ >
661
+ <ul
662
+ class="form-autocomplete__listbox"
663
+ role="listbox"
664
+ aria-label="Suggestions"
665
+ >
666
+ {#if isLoading}
667
+ <li class="form-autocomplete__status form-autocomplete__status--loading">
668
+ <Icon icon="heroicons:arrow-path" class="form-autocomplete__status-icon" />
669
+ <span>Loading suggestions...</span>
670
+ </li>
671
+ {:else if error}
672
+ <li class="form-autocomplete__status form-autocomplete__status--error">
673
+ <Icon icon="heroicons:exclamation-triangle" class="form-autocomplete__status-icon" />
674
+ <span>{error}</span>
675
+ <button type="button" class="form-autocomplete__retry" onclick={handleRetry}>
676
+ Retry
677
+ </button>
678
+ </li>
679
+ {:else if suggestions.length === 0}
680
+ <li class="form-autocomplete__status form-autocomplete__status--empty">
681
+ <Icon icon="heroicons:magnifying-glass" class="form-autocomplete__status-icon" />
682
+ <span>No results found</span>
683
+ </li>
684
+ {:else}
685
+ {#each suggestions as option, index (option.value)}
686
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
687
+ <li
688
+ id={getOptionId(index)}
689
+ class="form-autocomplete__option"
690
+ class:form-autocomplete__option--highlighted={index === highlightedIndex}
691
+ class:form-autocomplete__option--selected={isSelected(option.value)}
692
+ role="option"
693
+ aria-selected={isSelected(option.value)}
694
+ onmouseenter={() => (highlightedIndex = index)}
695
+ onclick={() => selectOption(option)}
696
+ >
697
+ <span class="form-autocomplete__option-label">{option.label}</span>
698
+ {#if isSelected(option.value)}
699
+ <Icon icon="heroicons:check" class="form-autocomplete__option-check" />
700
+ {/if}
701
+ </li>
702
+ {/each}
703
+ {/if}
704
+ </ul>
705
+ </div>
706
+ </div>
707
+
708
+ <style>
709
+ .form-autocomplete {
710
+ position: relative;
711
+ width: 100%;
712
+ }
713
+
714
+ .form-autocomplete--disabled {
715
+ opacity: 0.6;
716
+ pointer-events: none;
717
+ }
718
+
719
+ /* Field container styled like a textfield */
720
+ .form-autocomplete__field {
721
+ display: flex;
722
+ flex-wrap: wrap;
723
+ align-items: flex-start;
724
+ gap: var(--fd-space-1);
725
+ min-height: 2.625rem;
726
+ padding: var(--fd-space-1) 2.5rem var(--fd-space-1) var(--fd-space-3);
727
+ border: 1px solid var(--fd-border);
728
+ border-radius: var(--fd-radius-lg);
729
+ font-size: var(--fd-text-sm);
730
+ font-family: inherit;
731
+ color: var(--fd-foreground);
732
+ background-color: var(--fd-muted);
733
+ transition: all var(--fd-transition-normal);
734
+ box-shadow: var(--fd-shadow-sm);
735
+ cursor: text;
736
+ position: relative;
737
+ }
738
+
739
+ .form-autocomplete__field:hover {
740
+ border-color: var(--fd-border-strong);
741
+ background-color: var(--fd-background);
742
+ }
743
+
744
+ .form-autocomplete__field--focused {
745
+ border-color: var(--fd-primary);
746
+ background-color: var(--fd-background);
747
+ box-shadow:
748
+ 0 0 0 3px rgba(59, 130, 246, 0.12),
749
+ var(--fd-shadow-sm);
750
+ }
751
+
752
+ /* Multiple mode - textarea-like styling */
753
+ .form-autocomplete--multiple .form-autocomplete__field {
754
+ min-height: 3rem;
755
+ align-content: flex-start;
756
+ }
757
+
758
+ /* Tags container */
759
+ .form-autocomplete__tags {
760
+ display: flex;
761
+ flex-wrap: wrap;
762
+ gap: var(--fd-space-1);
763
+ align-items: center;
764
+ }
765
+
766
+ /* Individual tag - selected item chip */
767
+ .form-autocomplete__tag {
768
+ display: inline-flex;
769
+ align-items: center;
770
+ gap: var(--fd-space-1);
771
+ padding: var(--fd-space-1) var(--fd-space-1) var(--fd-space-1) var(--fd-space-2);
772
+ background-color: var(--fd-primary-muted);
773
+ border: 1px solid var(--fd-primary-muted);
774
+ border-radius: var(--fd-radius-md);
775
+ font-size: 0.8125rem;
776
+ color: var(--fd-primary-hover);
777
+ line-height: 1.2;
778
+ max-width: 100%;
779
+ }
780
+
781
+ .form-autocomplete__tag-label {
782
+ overflow: hidden;
783
+ text-overflow: ellipsis;
784
+ white-space: nowrap;
785
+ max-width: 12rem;
786
+ }
787
+
788
+ .form-autocomplete__tag-remove {
789
+ display: flex;
790
+ align-items: center;
791
+ justify-content: center;
792
+ padding: 0.125rem;
793
+ margin-left: 0.125rem;
794
+ border: none;
795
+ border-radius: var(--fd-radius-sm);
796
+ background: transparent;
797
+ color: var(--fd-primary);
798
+ cursor: pointer;
799
+ transition: all var(--fd-transition-fast);
800
+ flex-shrink: 0;
801
+ }
802
+
803
+ .form-autocomplete__tag-remove:hover {
804
+ background-color: var(--fd-primary-muted);
805
+ color: var(--fd-primary-hover);
806
+ }
807
+
808
+ .form-autocomplete__tag-remove :global(svg) {
809
+ width: 0.875rem;
810
+ height: 0.875rem;
811
+ }
812
+
813
+ /* Input area */
814
+ .form-autocomplete__input-area {
815
+ flex: 1;
816
+ min-width: 4rem;
817
+ display: flex;
818
+ align-items: center;
819
+ }
820
+
821
+ .form-autocomplete__input {
822
+ width: 100%;
823
+ padding: var(--fd-space-1) 0;
824
+ border: none;
825
+ outline: none;
826
+ font-size: var(--fd-text-sm);
827
+ font-family: inherit;
828
+ color: var(--fd-foreground);
829
+ background-color: transparent;
830
+ }
831
+
832
+ .form-autocomplete__input::placeholder {
833
+ color: var(--fd-muted-foreground);
834
+ }
835
+
836
+ /* Status icons */
837
+ .form-autocomplete__icons {
838
+ position: absolute;
839
+ right: var(--fd-space-2);
840
+ top: 0.625rem;
841
+ display: flex;
842
+ align-items: center;
843
+ gap: var(--fd-space-1);
844
+ }
845
+
846
+ .form-autocomplete__chevron,
847
+ .form-autocomplete__spinner {
848
+ display: flex;
849
+ align-items: center;
850
+ justify-content: center;
851
+ color: var(--fd-muted-foreground);
852
+ pointer-events: none;
853
+ }
854
+
855
+ .form-autocomplete__chevron :global(svg),
856
+ .form-autocomplete__spinner :global(svg) {
857
+ width: 1rem;
858
+ height: 1rem;
859
+ }
860
+
861
+ .form-autocomplete__spinner {
862
+ animation: autocomplete-spin 1s linear infinite;
863
+ }
864
+
865
+ @keyframes autocomplete-spin {
866
+ from {
867
+ transform: rotate(0deg);
868
+ }
869
+ to {
870
+ transform: rotate(360deg);
871
+ }
872
+ }
873
+
874
+ .form-autocomplete__clear {
875
+ display: flex;
876
+ align-items: center;
877
+ justify-content: center;
878
+ padding: var(--fd-space-1);
879
+ border: none;
880
+ border-radius: var(--fd-radius-sm);
881
+ background: transparent;
882
+ color: var(--fd-muted-foreground);
883
+ cursor: pointer;
884
+ transition: all var(--fd-transition-fast);
885
+ }
886
+
887
+ .form-autocomplete__clear:hover {
888
+ background-color: var(--fd-muted);
889
+ color: var(--fd-foreground);
890
+ }
891
+
892
+ .form-autocomplete__clear :global(svg) {
893
+ width: 1rem;
894
+ height: 1rem;
895
+ }
896
+
897
+ /* Popover container - renders in top layer via Popover API */
898
+ .form-autocomplete__popover {
899
+ position: fixed;
900
+ margin: 0;
901
+ padding: 0;
902
+ border: none;
903
+ background: transparent;
904
+ overflow: visible;
905
+ }
906
+
907
+ /* Reset default popover styles */
908
+ .form-autocomplete__popover:popover-open {
909
+ display: block;
910
+ }
911
+
912
+ /* Dropdown listbox inside popover */
913
+ .form-autocomplete__listbox {
914
+ margin: 0;
915
+ padding: var(--fd-space-1);
916
+ list-style: none;
917
+ background-color: var(--fd-background);
918
+ border: 1px solid var(--fd-border);
919
+ border-radius: var(--fd-radius-lg);
920
+ box-shadow: var(--fd-shadow-lg);
921
+ overflow-y: auto;
922
+ max-height: inherit;
923
+ }
924
+
925
+ .form-autocomplete__option {
926
+ display: flex;
927
+ align-items: center;
928
+ justify-content: space-between;
929
+ padding: var(--fd-space-2) var(--fd-space-3);
930
+ border-radius: var(--fd-radius-md);
931
+ cursor: pointer;
932
+ transition: background-color var(--fd-transition-fast);
933
+ }
934
+
935
+ .form-autocomplete__option:hover,
936
+ .form-autocomplete__option--highlighted {
937
+ background-color: var(--fd-muted);
938
+ }
939
+
940
+ .form-autocomplete__option--selected {
941
+ background-color: var(--fd-primary-muted);
942
+ }
943
+
944
+ .form-autocomplete__option--highlighted.form-autocomplete__option--selected {
945
+ background-color: var(--fd-primary-muted);
946
+ }
947
+
948
+ .form-autocomplete__option-label {
949
+ font-size: var(--fd-text-sm);
950
+ color: var(--fd-foreground);
951
+ flex: 1;
952
+ overflow: hidden;
953
+ text-overflow: ellipsis;
954
+ white-space: nowrap;
955
+ }
956
+
957
+ .form-autocomplete__option :global(.form-autocomplete__option-check) {
958
+ width: 1rem;
959
+ height: 1rem;
960
+ color: var(--fd-primary);
961
+ flex-shrink: 0;
962
+ margin-left: var(--fd-space-2);
963
+ }
964
+
965
+ /* Status messages */
966
+ .form-autocomplete__status {
967
+ display: flex;
968
+ align-items: center;
969
+ gap: var(--fd-space-2);
970
+ padding: var(--fd-space-3);
971
+ font-size: var(--fd-text-sm);
972
+ color: var(--fd-muted-foreground);
973
+ }
974
+
975
+ .form-autocomplete__status--loading {
976
+ color: var(--fd-primary);
977
+ }
978
+
979
+ .form-autocomplete__status--error {
980
+ color: var(--fd-error);
981
+ flex-wrap: wrap;
982
+ }
983
+
984
+ .form-autocomplete__status--empty {
985
+ color: var(--fd-muted-foreground);
986
+ }
987
+
988
+ .form-autocomplete__status :global(.form-autocomplete__status-icon) {
989
+ width: 1rem;
990
+ height: 1rem;
991
+ flex-shrink: 0;
992
+ }
993
+
994
+ .form-autocomplete__status--loading :global(.form-autocomplete__status-icon) {
995
+ animation: autocomplete-spin 1s linear infinite;
996
+ }
997
+
998
+ .form-autocomplete__retry {
999
+ margin-left: auto;
1000
+ padding: var(--fd-space-1) var(--fd-space-2);
1001
+ border: 1px solid var(--fd-error);
1002
+ border-radius: var(--fd-radius-sm);
1003
+ background-color: transparent;
1004
+ color: var(--fd-error);
1005
+ font-size: var(--fd-text-xs);
1006
+ font-weight: 500;
1007
+ cursor: pointer;
1008
+ transition: all var(--fd-transition-fast);
1009
+ }
1010
+
1011
+ .form-autocomplete__retry:hover {
1012
+ background-color: var(--fd-error-muted);
1013
+ }
1014
+ </style>