@spaethtech/svelte-ui 0.1.10

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 (116) hide show
  1. package/README.md +81 -0
  2. package/dist/components/Badge/Badge.svelte +80 -0
  3. package/dist/components/Badge/Badge.svelte.d.ts +14 -0
  4. package/dist/components/Badge/index.d.ts +1 -0
  5. package/dist/components/Badge/index.js +1 -0
  6. package/dist/components/Button/Button.svelte +172 -0
  7. package/dist/components/Button/Button.svelte.d.ts +32 -0
  8. package/dist/components/Button/index.d.ts +1 -0
  9. package/dist/components/Button/index.js +1 -0
  10. package/dist/components/Dialog/Dialog.svelte +101 -0
  11. package/dist/components/Dialog/Dialog.svelte.d.ts +18 -0
  12. package/dist/components/Dialog/index.d.ts +1 -0
  13. package/dist/components/Dialog/index.js +1 -0
  14. package/dist/components/ElementManager/ElementManager.svelte +397 -0
  15. package/dist/components/ElementManager/ElementManager.svelte.d.ts +43 -0
  16. package/dist/components/ElementManager/index.d.ts +1 -0
  17. package/dist/components/ElementManager/index.js +1 -0
  18. package/dist/components/EmailInput/EmailInput.svelte +47 -0
  19. package/dist/components/EmailInput/EmailInput.svelte.d.ts +14 -0
  20. package/dist/components/EmailInput/index.d.ts +1 -0
  21. package/dist/components/EmailInput/index.js +1 -0
  22. package/dist/components/Icon/Icon.svelte +74 -0
  23. package/dist/components/Icon/Icon.svelte.d.ts +13 -0
  24. package/dist/components/Icon/index.d.ts +1 -0
  25. package/dist/components/Icon/index.js +1 -0
  26. package/dist/components/Input/Input.svelte +268 -0
  27. package/dist/components/Input/Input.svelte.d.ts +18 -0
  28. package/dist/components/Input/index.d.ts +1 -0
  29. package/dist/components/Input/index.js +1 -0
  30. package/dist/components/List/List.svelte +580 -0
  31. package/dist/components/List/List.svelte.d.ts +38 -0
  32. package/dist/components/List/index.d.ts +1 -0
  33. package/dist/components/List/index.js +1 -0
  34. package/dist/components/ListItem/ListItem.svelte +175 -0
  35. package/dist/components/ListItem/ListItem.svelte.d.ts +24 -0
  36. package/dist/components/ListItem/index.d.ts +2 -0
  37. package/dist/components/ListItem/index.js +2 -0
  38. package/dist/components/ListView/ListView.svelte +463 -0
  39. package/dist/components/ListView/ListView.svelte.d.ts +37 -0
  40. package/dist/components/ListView/index.d.ts +2 -0
  41. package/dist/components/ListView/index.js +2 -0
  42. package/dist/components/NodeGraph/BaseNode.d.ts +57 -0
  43. package/dist/components/NodeGraph/BaseNode.js +30 -0
  44. package/dist/components/NodeGraph/Edge.svelte +60 -0
  45. package/dist/components/NodeGraph/Edge.svelte.d.ts +16 -0
  46. package/dist/components/NodeGraph/ExpressionEvaluator.d.ts +82 -0
  47. package/dist/components/NodeGraph/ExpressionEvaluator.js +152 -0
  48. package/dist/components/NodeGraph/Node.svelte +100 -0
  49. package/dist/components/NodeGraph/Node.svelte.d.ts +10 -0
  50. package/dist/components/NodeGraph/NodeGraph.svelte +52 -0
  51. package/dist/components/NodeGraph/NodeGraph.svelte.d.ts +14 -0
  52. package/dist/components/NodeGraph/NodeInstance.svelte.d.ts +80 -0
  53. package/dist/components/NodeGraph/NodeInstance.svelte.js +241 -0
  54. package/dist/components/NodeGraph/NodePort.svelte +75 -0
  55. package/dist/components/NodeGraph/NodePort.svelte.d.ts +11 -0
  56. package/dist/components/NodeGraph/decorators.d.ts +81 -0
  57. package/dist/components/NodeGraph/decorators.js +148 -0
  58. package/dist/components/NodeGraph/index.d.ts +9 -0
  59. package/dist/components/NodeGraph/index.js +9 -0
  60. package/dist/components/NodeGraph/types.d.ts +94 -0
  61. package/dist/components/NodeGraph/types.js +33 -0
  62. package/dist/components/NotesEditor/NotesEditor.svelte +203 -0
  63. package/dist/components/NotesEditor/NotesEditor.svelte.d.ts +9 -0
  64. package/dist/components/NotesEditor/index.d.ts +1 -0
  65. package/dist/components/NotesEditor/index.js +1 -0
  66. package/dist/components/NumberInput/NumberInput.svelte +353 -0
  67. package/dist/components/NumberInput/NumberInput.svelte.d.ts +26 -0
  68. package/dist/components/NumberInput/index.d.ts +1 -0
  69. package/dist/components/NumberInput/index.js +1 -0
  70. package/dist/components/PasswordInput/PasswordInput.svelte +41 -0
  71. package/dist/components/PasswordInput/PasswordInput.svelte.d.ts +13 -0
  72. package/dist/components/PasswordInput/index.d.ts +1 -0
  73. package/dist/components/PasswordInput/index.js +1 -0
  74. package/dist/components/Popup/index.d.ts +1 -0
  75. package/dist/components/Popup/index.js +1 -0
  76. package/dist/components/Rating/Rating.svelte +77 -0
  77. package/dist/components/Rating/Rating.svelte.d.ts +7 -0
  78. package/dist/components/Rating/index.d.ts +1 -0
  79. package/dist/components/Rating/index.js +1 -0
  80. package/dist/components/ScrollView/ScrollView.svelte +285 -0
  81. package/dist/components/ScrollView/ScrollView.svelte.d.ts +19 -0
  82. package/dist/components/ScrollView/index.d.ts +1 -0
  83. package/dist/components/ScrollView/index.js +1 -0
  84. package/dist/components/SearchInput/SearchInput.svelte +50 -0
  85. package/dist/components/SearchInput/SearchInput.svelte.d.ts +15 -0
  86. package/dist/components/SearchInput/index.d.ts +1 -0
  87. package/dist/components/SearchInput/index.js +1 -0
  88. package/dist/components/Select/Select.svelte +278 -0
  89. package/dist/components/Select/Select.svelte.d.ts +35 -0
  90. package/dist/components/Select/index.d.ts +1 -0
  91. package/dist/components/Select/index.js +1 -0
  92. package/dist/components/TextArea/TextArea.svelte +734 -0
  93. package/dist/components/TextArea/TextArea.svelte.d.ts +19 -0
  94. package/dist/components/TextArea/index.d.ts +1 -0
  95. package/dist/components/TextArea/index.js +1 -0
  96. package/dist/components/ThemeSelector/ThemeSelector.svelte +64 -0
  97. package/dist/components/ThemeSelector/ThemeSelector.svelte.d.ts +3 -0
  98. package/dist/components/ThemeSelector/index.d.ts +1 -0
  99. package/dist/components/ThemeSelector/index.js +1 -0
  100. package/dist/components/TooltipHandler/TooltipHandler.svelte +593 -0
  101. package/dist/components/TooltipHandler/TooltipHandler.svelte.d.ts +10 -0
  102. package/dist/components/TooltipHandler/index.d.ts +1 -0
  103. package/dist/components/TooltipHandler/index.js +1 -0
  104. package/dist/index.d.ts +24 -0
  105. package/dist/index.js +26 -0
  106. package/dist/styles/sizes.d.ts +78 -0
  107. package/dist/styles/sizes.js +120 -0
  108. package/dist/styles/variants.d.ts +106 -0
  109. package/dist/styles/variants.js +194 -0
  110. package/dist/types/ManagerTypes.d.ts +42 -0
  111. package/dist/types/ManagerTypes.js +1 -0
  112. package/dist/types/sizes.d.ts +5 -0
  113. package/dist/types/sizes.js +1 -0
  114. package/dist/types/variants.d.ts +1 -0
  115. package/dist/types/variants.js +1 -0
  116. package/package.json +65 -0
@@ -0,0 +1,580 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
+
5
+ type Option = Record<string, any> & {
6
+ value: string;
7
+ label: string;
8
+ };
9
+
10
+ interface Props {
11
+ options: Option[];
12
+ value?: string | string[]; // Bindable selected value - string[] when multiSelect is true
13
+ multiSelect?: boolean; // Enable multi-select mode
14
+ showCheckboxes?: boolean; // Show checkboxes for selection (implies multiSelect)
15
+ maxVisibleItems?: number; // When not set, grows to fit all items
16
+ itemHeight?: number;
17
+ minHeight?: number; // Minimum height in pixels (default 32px)
18
+ class?: string;
19
+ dropdownClass?: string;
20
+ itemClass?: string;
21
+ itemSnippet?: Snippet<[Option, number, boolean, boolean]>; // option, index, isSelected, isFocused
22
+ disabled?: boolean;
23
+ // API integration
24
+ url?: string;
25
+ searchParam?: string;
26
+ transform?: (apiResponse: any) => Option[];
27
+ debounceMs?: number;
28
+ maxResults?: number;
29
+ headers?: Record<string, string>;
30
+ searchTerm?: string;
31
+ showLoading?: boolean;
32
+ loadingText?: string;
33
+ showError?: boolean;
34
+ errorText?: string;
35
+ emptyText?: string;
36
+ onError?: (error: Error) => void;
37
+ onLoadStart?: () => void;
38
+ onLoadEnd?: () => void;
39
+ onSelection?: (option: Option) => void;
40
+ }
41
+
42
+ let {
43
+ options = [],
44
+ value = $bindable(),
45
+ multiSelect = false,
46
+ showCheckboxes = false,
47
+ maxVisibleItems, // Optional - when not set, grows to fit all items
48
+ itemHeight = 28, // Default: var(--ui-height) - (var(--ui-border) * 2) = 32px - 4px = 28px
49
+ minHeight = 32, // Should match var(--ui-height) for standard UI element height
50
+ class: className = "",
51
+ dropdownClass = "",
52
+ itemClass = "",
53
+ itemSnippet,
54
+ disabled = false,
55
+ // API integration props
56
+ url,
57
+ searchParam,
58
+ transform,
59
+ debounceMs = 300,
60
+ maxResults,
61
+ headers = {},
62
+ searchTerm = $bindable(""),
63
+ showLoading = true,
64
+ loadingText = "Loading...",
65
+ showError = true,
66
+ errorText = "Failed to load data",
67
+ emptyText = "No data",
68
+ onError,
69
+ onLoadStart,
70
+ onLoadEnd,
71
+ onSelection
72
+ }: Props = $props();
73
+
74
+ // showCheckboxes implies multiSelect
75
+ const effectiveMultiSelect = multiSelect || showCheckboxes;
76
+
77
+ // Initialize value based on multiSelect mode
78
+ if (value === undefined) {
79
+ value = effectiveMultiSelect ? [] : "";
80
+ }
81
+
82
+ // Helper functions for multi-select
83
+ function isSelected(optionValue: string): boolean {
84
+ if (effectiveMultiSelect) {
85
+ return Array.isArray(value) && value.includes(optionValue);
86
+ }
87
+ return value === optionValue;
88
+ }
89
+
90
+ function toggleSelection(optionValue: string) {
91
+ if (effectiveMultiSelect && Array.isArray(value)) {
92
+ if (value.includes(optionValue)) {
93
+ value = value.filter(v => v !== optionValue);
94
+ } else {
95
+ value = [...value, optionValue];
96
+ }
97
+ } else {
98
+ value = optionValue;
99
+ }
100
+ }
101
+
102
+ // API integration state
103
+ let isLoading = $state(false);
104
+ let hasError = $state(false);
105
+ let currentError = $state<Error | null>(null);
106
+ let apiOptions = $state<Option[]>([]);
107
+ let lastSearchTerm = $state("");
108
+ let abortController = $state<AbortController | null>(null);
109
+
110
+ // Combine static options with API options
111
+ const allOptions = $derived(() => {
112
+ if (url && apiOptions.length > 0) {
113
+ // When using API, prioritize API results
114
+ return apiOptions;
115
+ }
116
+ // Use static options (default behavior)
117
+ return options;
118
+ });
119
+
120
+ // Get actual item heights from DOM elements when using custom snippets
121
+ const getActualItemHeight = $derived(() => {
122
+ if (itemSnippet && itemElements.length > 0) {
123
+ // Use the actual height of the first rendered item
124
+ const firstItem = itemElements[0];
125
+ return firstItem ? firstItem.offsetHeight : itemHeight;
126
+ }
127
+ return itemHeight;
128
+ });
129
+
130
+ // Calculate height - grows to fit all items when maxVisibleItems is not set
131
+ // Always respects minHeight (32px standard)
132
+ const maxHeight = $derived(() => {
133
+ const options = allOptions();
134
+ const actualItemHeight = getActualItemHeight();
135
+ let calculatedHeight;
136
+
137
+ // Account for border: var(--ui-border) * 2 for normal state, 1px * 2 for disabled
138
+ const borderOffset = disabled ? 2 : 4; // TODO: Use CSS var(--ui-border) when possible
139
+
140
+ if (maxVisibleItems !== undefined) {
141
+ // Fixed height mode - use maxVisibleItems
142
+ calculatedHeight = Math.min(options.length, maxVisibleItems) * actualItemHeight + borderOffset;
143
+ } else {
144
+ // Auto-grow mode - fit all items
145
+ calculatedHeight = options.length * actualItemHeight + borderOffset;
146
+ }
147
+
148
+ // Ensure minimum height is respected
149
+ return Math.max(calculatedHeight, minHeight);
150
+ });
151
+
152
+ let listElement = $state<HTMLDivElement>();
153
+ let scrollThumb = $state<HTMLDivElement>();
154
+ let isDragging = $state(false);
155
+ let dragStartY = $state(0);
156
+ let dragStartScrollTop = $state(0);
157
+ let scrollTop = $state(0);
158
+ let thumbTop = $state(0);
159
+ let thumbHeight = $state(20);
160
+ let focusedIndex = $state(-1);
161
+ let keyboardCursor = $state(-1); // Tracks last interaction position for keyboard navigation
162
+ let itemElements = $state<HTMLElement[]>([]);
163
+
164
+ function selectOption(optionValue: string, event: MouseEvent) {
165
+ if (disabled) return;
166
+
167
+ event.stopPropagation();
168
+
169
+ // Update keyboard cursor to the clicked item
170
+ const clickedIndex = allOptions().findIndex(option => option.value === optionValue);
171
+ if (clickedIndex >= 0) {
172
+ keyboardCursor = clickedIndex;
173
+ }
174
+
175
+ // Update bindable value
176
+ toggleSelection(optionValue);
177
+
178
+ // Call onSelection callback with the full option object
179
+ const selectedOption = allOptions().find(option => option.value === optionValue);
180
+ if (selectedOption && onSelection) {
181
+ onSelection(selectedOption);
182
+ }
183
+ }
184
+
185
+ // Handle list focus - restore keyboard cursor position and scroll into view
186
+ function handleListFocus() {
187
+ // Set keyboard cursor to first item if none exists and options are available
188
+ if (keyboardCursor < 0 && allOptions().length > 0) {
189
+ keyboardCursor = 0;
190
+ }
191
+
192
+ // If we have a keyboard cursor position, restore focus highlighting and scroll into view
193
+ if (keyboardCursor >= 0) {
194
+ focusedIndex = keyboardCursor;
195
+
196
+ // Scroll into view if the list is scrollable
197
+ if (listElement) {
198
+ const actualItemHeight = getActualItemHeight();
199
+ const itemTop = keyboardCursor * actualItemHeight;
200
+ const itemBottom = itemTop + actualItemHeight;
201
+ const viewTop = listElement.scrollTop;
202
+ const currentMaxHeight = maxHeight();
203
+ const viewBottom = viewTop + currentMaxHeight;
204
+
205
+ if (itemTop < viewTop || itemBottom > viewBottom) {
206
+ // Keyboard cursor is not visible, scroll it into view
207
+ listElement.scrollTop = Math.max(0, itemTop - (currentMaxHeight - actualItemHeight) / 2);
208
+ }
209
+ }
210
+ } else {
211
+ // No options available, reset focused index
212
+ focusedIndex = -1;
213
+ }
214
+ }
215
+
216
+ // Handle list blur - reset focused index
217
+ function handleListBlur() {
218
+ focusedIndex = -1;
219
+ }
220
+
221
+ // API fetch function
222
+ async function fetchOptions(searchQuery: string) {
223
+ if (!url) {
224
+ apiOptions = [];
225
+ return;
226
+ }
227
+
228
+ // If no searchParam is provided, fetch all data (ignore searchQuery)
229
+ // If searchParam is provided but searchQuery is empty, clear results
230
+ if (searchParam && !searchQuery.trim()) {
231
+ apiOptions = [];
232
+ return;
233
+ }
234
+
235
+ // Cancel previous request
236
+ if (abortController) {
237
+ abortController.abort();
238
+ }
239
+
240
+ abortController = new AbortController();
241
+
242
+ try {
243
+ isLoading = true;
244
+ onLoadStart?.();
245
+
246
+ // Build URL with optional search parameter
247
+ const apiUrl = new URL(url, window.location.origin);
248
+ if (searchParam && searchQuery.trim()) {
249
+ apiUrl.searchParams.set(searchParam, searchQuery);
250
+ }
251
+
252
+ const response = await fetch(apiUrl.toString(), {
253
+ signal: abortController.signal,
254
+ headers: {
255
+ 'Content-Type': 'application/json',
256
+ ...headers
257
+ }
258
+ });
259
+
260
+ if (!response.ok) {
261
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
262
+ }
263
+
264
+ const data = await response.json();
265
+
266
+ // Transform the response using provided transform function
267
+ let transformedOptions = transform ? transform(data) : data;
268
+
269
+ // Apply maxResults limit if specified
270
+ if (maxResults && transformedOptions.length > maxResults) {
271
+ transformedOptions = transformedOptions.slice(0, maxResults);
272
+ }
273
+
274
+ apiOptions = transformedOptions;
275
+ lastSearchTerm = searchQuery;
276
+
277
+ } catch (error) {
278
+ if (error instanceof Error && error.name !== 'AbortError') {
279
+ console.error('List API fetch error:', error);
280
+ hasError = true;
281
+ currentError = error;
282
+ onError?.(error);
283
+ apiOptions = [];
284
+ }
285
+ } finally {
286
+ isLoading = false;
287
+ onLoadEnd?.();
288
+ abortController = null;
289
+ }
290
+ }
291
+
292
+ // API fetch effect for search functionality
293
+ $effect(() => {
294
+ if (url && searchParam) {
295
+ // If searchParam is provided, use debounced search
296
+ if (searchTerm.trim() && searchTerm !== lastSearchTerm) {
297
+ const timeoutId = setTimeout(() => {
298
+ fetchOptions(searchTerm);
299
+ }, debounceMs);
300
+
301
+ return () => clearTimeout(timeoutId);
302
+ } else if (!searchTerm.trim()) {
303
+ // Clear results when search term is empty
304
+ apiOptions = [];
305
+ }
306
+ }
307
+ });
308
+
309
+ // Initial API fetch for static data (no search) - run once on mount
310
+ $effect(() => {
311
+ // Only run if we have a URL but no search parameter (static data load)
312
+ if (url && !searchParam) {
313
+ // Use untrack to prevent reactivity and only run once
314
+ untrack(() => fetchOptions(""));
315
+ }
316
+ });
317
+
318
+ // Clear error state when search term changes
319
+ $effect(() => {
320
+ if (searchTerm !== lastSearchTerm) {
321
+ hasError = false;
322
+ currentError = null;
323
+ }
324
+ });
325
+
326
+ // Handle keyboard navigation within list
327
+ function handleListKeydown(event: KeyboardEvent) {
328
+ if (disabled) return;
329
+
330
+ if (event.key === 'Tab') {
331
+ // Allow normal tab navigation to move to next element
332
+ return;
333
+ }
334
+
335
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
336
+ event.preventDefault();
337
+ event.stopPropagation(); // Prevent parent dialog from scrolling
338
+
339
+ // If no item is focused yet, start from keyboard cursor position and move from there
340
+ if (focusedIndex === -1) {
341
+ // Start from keyboard cursor if available, otherwise start from beginning
342
+ const startIndex = keyboardCursor >= 0 ? keyboardCursor : 0;
343
+
344
+ if (event.key === 'ArrowDown') {
345
+ focusedIndex = Math.min(startIndex + 1, allOptions().length - 1);
346
+ } else {
347
+ focusedIndex = Math.max(startIndex - 1, 0);
348
+ }
349
+ } else {
350
+ // Normal navigation
351
+ if (event.key === 'ArrowDown') {
352
+ focusedIndex = Math.min(focusedIndex + 1, allOptions().length - 1);
353
+ } else {
354
+ focusedIndex = Math.max(focusedIndex - 1, 0);
355
+ }
356
+ }
357
+
358
+ // Update keyboard cursor to track current navigation position
359
+ keyboardCursor = focusedIndex;
360
+
361
+ // Scroll to keep focused item visible (only needed when maxVisibleItems is set)
362
+ if (listElement && maxVisibleItems !== undefined) {
363
+ const actualItemHeight = getActualItemHeight();
364
+ const itemTop = focusedIndex * actualItemHeight;
365
+ const itemBottom = itemTop + actualItemHeight;
366
+ const viewTop = listElement.scrollTop;
367
+ const currentMaxHeight = maxHeight();
368
+ const viewBottom = viewTop + currentMaxHeight;
369
+
370
+ if (itemTop < viewTop) {
371
+ // Item is above visible area
372
+ listElement.scrollTop = itemTop;
373
+ } else if (itemBottom > viewBottom) {
374
+ // Item is below visible area
375
+ listElement.scrollTop = itemBottom - currentMaxHeight;
376
+ }
377
+ }
378
+ } else if (event.key === ' ') {
379
+ event.preventDefault();
380
+ event.stopPropagation(); // Prevent page scroll
381
+ if (focusedIndex >= 0 && focusedIndex < allOptions().length) {
382
+ const selectedOption = allOptions()[focusedIndex];
383
+
384
+ // Update bindable value using toggle logic
385
+ toggleSelection(selectedOption.value);
386
+
387
+ // Call onSelection callback
388
+ if (onSelection) {
389
+ onSelection(selectedOption);
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ // Custom scrollbar functions
396
+ const scrollThumbHeight = $derived(() => {
397
+ if (!listElement || maxVisibleItems === undefined) return 20;
398
+ const actualItemHeight = getActualItemHeight();
399
+ const totalContentHeight = allOptions().length * actualItemHeight;
400
+ const currentMaxHeight = maxHeight();
401
+ const visibleRatio = currentMaxHeight / totalContentHeight;
402
+ return Math.max(20, Math.min(currentMaxHeight - 4, visibleRatio * currentMaxHeight)); // Minimum 20px, max container height - 4px
403
+ });
404
+
405
+ function handleThumbMouseDown(event: MouseEvent) {
406
+ if (!listElement) return;
407
+ event.preventDefault();
408
+ isDragging = true;
409
+ dragStartY = event.clientY;
410
+ dragStartScrollTop = listElement.scrollTop;
411
+
412
+ document.addEventListener('mousemove', handleThumbMouseMove);
413
+ document.addEventListener('mouseup', handleThumbMouseUp);
414
+ }
415
+
416
+ function handleThumbMouseMove(event: MouseEvent) {
417
+ if (!isDragging || !listElement || maxVisibleItems === undefined) return;
418
+
419
+ const deltaY = event.clientY - dragStartY;
420
+ const currentMaxHeight = maxHeight();
421
+ const scrollRatio = deltaY / (currentMaxHeight - scrollThumbHeight());
422
+ const maxScrollTop = listElement.scrollHeight - listElement.clientHeight;
423
+
424
+ listElement.scrollTop = Math.max(0, Math.min(maxScrollTop, dragStartScrollTop + scrollRatio * maxScrollTop));
425
+ }
426
+
427
+ function handleThumbMouseUp() {
428
+ isDragging = false;
429
+ document.removeEventListener('mousemove', handleThumbMouseMove);
430
+ document.removeEventListener('mouseup', handleThumbMouseUp);
431
+ }
432
+
433
+ // Update scroll thumb position on scroll
434
+ function handleScroll() {
435
+ if (listElement) {
436
+ scrollTop = listElement.scrollTop;
437
+ }
438
+ }
439
+
440
+ // Watch for scroll changes with an effect
441
+ $effect(() => {
442
+ if (listElement && maxVisibleItems !== undefined) {
443
+ const updateScrollPosition = () => {
444
+ if (!listElement || maxVisibleItems === undefined) return;
445
+
446
+ const trackPadding = 4; // 4px padding on top and bottom
447
+ const actualItemHeight = getActualItemHeight();
448
+ const totalContentHeight = allOptions().length * actualItemHeight;
449
+ const currentMaxHeight = maxHeight();
450
+ const visibleRatio = currentMaxHeight / totalContentHeight;
451
+ thumbHeight = Math.max(20, Math.min(currentMaxHeight - (trackPadding * 2) - 4, visibleRatio * currentMaxHeight));
452
+
453
+ const maxScrollTop = listElement.scrollHeight - listElement.clientHeight;
454
+ if (maxScrollTop > 0) {
455
+ const scrollRatio = listElement.scrollTop / maxScrollTop;
456
+ const trackHeight = currentMaxHeight - thumbHeight - (trackPadding * 2);
457
+ thumbTop = trackPadding + (scrollRatio * trackHeight);
458
+ }
459
+
460
+ scrollTop = listElement.scrollTop;
461
+ };
462
+
463
+ // Initial calculation
464
+ setTimeout(updateScrollPosition, 10);
465
+
466
+ listElement.addEventListener('scroll', updateScrollPosition);
467
+ return () => {
468
+ if (listElement) {
469
+ listElement.removeEventListener('scroll', updateScrollPosition);
470
+ }
471
+ };
472
+ }
473
+ });
474
+ </script>
475
+
476
+ <div class="relative {className}">
477
+ <div class="relative">
478
+ <div
479
+ bind:this={listElement}
480
+ class="{disabled ? '[background-color:color-mix(in_srgb,var(--color-text)_5%,transparent)] [border-color:color-mix(in_srgb,var(--color-secondary)_50%,transparent)] border cursor-not-allowed' : '[background-color:var(--color-background)] [border-color:var(--color-secondary)] hover:[border-color:color-mix(in_srgb,var(--color-secondary)_80%,black)] focus:[border-color:color-mix(in_srgb,var(--color-secondary)_90%,black)] focus:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)] focus:[outline-offset:0px]'} rounded-md overflow-y-scroll custom-list {dropdownClass}"
481
+ style="height: {maxHeight()}px; scrollbar-width: none; -ms-overflow-style: none; overscroll-behavior: contain; {disabled ? 'border-width: 1px;' : 'border-width: var(--ui-border);'}"
482
+ onscroll={handleScroll}
483
+ onkeydown={handleListKeydown}
484
+ onfocus={handleListFocus}
485
+ onblur={handleListBlur}
486
+ tabindex={disabled ? -1 : 0}
487
+ role="listbox"
488
+ aria-label="List of options"
489
+ aria-busy={isLoading}
490
+ aria-disabled={disabled}
491
+ >
492
+ {#if isLoading && showLoading}
493
+ <div class="flex items-center justify-center py-4 [color:var(--color-secondary)]">
494
+ <div class="animate-spin rounded-full h-4 w-4 border-b-2 [border-color:var(--color-secondary)] mr-2"></div>
495
+ {loadingText}
496
+ </div>
497
+ {:else if hasError && showError}
498
+ <div class="flex items-center justify-center py-4 [color:var(--color-danger)]">
499
+ <span class="text-sm [color:var(--color-danger)]">{errorText}</span>
500
+ </div>
501
+ {:else if allOptions().length === 0}
502
+ <div class="flex items-center justify-center [color:var(--color-secondary)] select-none" style="height: {itemHeight}px;">
503
+ <span class="px-4 text-sm [color:var(--color-secondary)]">{emptyText}</span>
504
+ </div>
505
+ {:else}
506
+ {#each allOptions() as option, index (option.value)}
507
+ <button
508
+ bind:this={itemElements[index]}
509
+ type="button"
510
+ class="w-full text-left focus:outline-none flex items-center {disabled ? 'cursor-not-allowed [color:color-mix(in_srgb,var(--color-text)_40%,transparent)]' : 'cursor-pointer [color:var(--color-text)]'} {showCheckboxes ? (focusedIndex === index ? '[background-color:var(--color-secondary)] hover:[background-color:var(--color-secondary)] focus:[background-color:var(--color-secondary)]' : 'bg-transparent hover:[background-color:var(--color-secondary)] focus:[background-color:var(--color-secondary)]') : (isSelected(option.value) ? '[background-color:color-mix(in_srgb,var(--color-secondary)_80%,black)] [color:var(--color-text)] hover:[background-color:color-mix(in_srgb,var(--color-secondary)_80%,black)] focus:[background-color:color-mix(in_srgb,var(--color-secondary)_80%,black)]' : focusedIndex === index ? '[background-color:var(--color-secondary)] hover:[background-color:var(--color-secondary)] focus:[background-color:var(--color-secondary)]' : 'bg-transparent hover:[background-color:var(--color-secondary)] focus:[background-color:var(--color-secondary)]')} {itemClass}"
511
+ style="border: none; border-radius: 0; justify-content: flex-start; {itemSnippet ? '' : `height: ${itemHeight}px;`}"
512
+ onmousedown={(event) => selectOption(option.value, event)}
513
+ tabindex="-1"
514
+ role="option"
515
+ aria-selected={isSelected(option.value)}
516
+ aria-label={option.label}
517
+ disabled={disabled}
518
+ >
519
+ {#if itemSnippet}
520
+ {@render itemSnippet(option, index, isSelected(option.value), focusedIndex === index)}
521
+ {:else if showCheckboxes}
522
+ <div class="flex items-center gap-3 px-2 py-2 w-full">
523
+ <div class="relative">
524
+ <input
525
+ type="checkbox"
526
+ checked={isSelected(option.value)}
527
+ disabled={disabled}
528
+ class="w-4 h-4 rounded [border-color:var(--color-secondary)] {isSelected(option.value) ? '[background-color:var(--color-primary)] [border-color:var(--color-primary)]' : '[background-color:var(--color-background)]'} {disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
529
+ tabindex="-1"
530
+ readonly
531
+ />
532
+ {#if isSelected(option.value)}
533
+ <svg class="absolute inset-0 w-4 h-4 text-white pointer-events-none" fill="currentColor" viewBox="0 0 20 20">
534
+ <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
535
+ </svg>
536
+ {/if}
537
+ </div>
538
+ <span class="flex-1 text-left text-sm">{option.label}</span>
539
+ </div>
540
+ {:else}
541
+ <span class="px-2 w-full text-sm leading-tight">{option.label}</span>
542
+ {/if}
543
+ </button>
544
+ {/each}
545
+ {/if}
546
+ </div>
547
+
548
+ <!-- Custom scrollbar thumb - only show when maxVisibleItems is set and exceeded -->
549
+ {#if maxVisibleItems !== undefined && allOptions().length > maxVisibleItems && !isLoading && !hasError}
550
+ <div
551
+ class="absolute right-1 [background-color:color-mix(in_srgb,var(--color-text)_60%,transparent)] rounded cursor-default hover:[background-color:color-mix(in_srgb,var(--color-text)_80%,transparent)] transition-colors z-10"
552
+ style="top: {thumbTop}px; height: {thumbHeight}px; width: 6px;"
553
+ bind:this={scrollThumb}
554
+ onmousedown={handleThumbMouseDown}
555
+ role="scrollbar"
556
+ aria-orientation="vertical"
557
+ aria-controls="custom-list"
558
+ aria-valuenow={listElement ? Math.round((listElement.scrollTop / (listElement.scrollHeight - listElement.clientHeight)) * 100) : 0}
559
+ aria-valuemin="0"
560
+ aria-valuemax="100"
561
+ tabindex="-1"
562
+ aria-label="Scroll thumb"
563
+ ></div>
564
+ {/if}
565
+ </div>
566
+ </div>
567
+
568
+ <style>
569
+ /* Hide default scrollbar */
570
+ .custom-list::-webkit-scrollbar {
571
+ display: none;
572
+ }
573
+
574
+ .custom-list {
575
+ scrollbar-width: none;
576
+ -ms-overflow-style: none;
577
+ /* Prevent scroll chaining to parent elements */
578
+ overscroll-behavior: contain;
579
+ }
580
+ </style>
@@ -0,0 +1,38 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Option = Record<string, any> & {
3
+ value: string;
4
+ label: string;
5
+ };
6
+ interface Props {
7
+ options: Option[];
8
+ value?: string | string[];
9
+ multiSelect?: boolean;
10
+ showCheckboxes?: boolean;
11
+ maxVisibleItems?: number;
12
+ itemHeight?: number;
13
+ minHeight?: number;
14
+ class?: string;
15
+ dropdownClass?: string;
16
+ itemClass?: string;
17
+ itemSnippet?: Snippet<[Option, number, boolean, boolean]>;
18
+ disabled?: boolean;
19
+ url?: string;
20
+ searchParam?: string;
21
+ transform?: (apiResponse: any) => Option[];
22
+ debounceMs?: number;
23
+ maxResults?: number;
24
+ headers?: Record<string, string>;
25
+ searchTerm?: string;
26
+ showLoading?: boolean;
27
+ loadingText?: string;
28
+ showError?: boolean;
29
+ errorText?: string;
30
+ emptyText?: string;
31
+ onError?: (error: Error) => void;
32
+ onLoadStart?: () => void;
33
+ onLoadEnd?: () => void;
34
+ onSelection?: (option: Option) => void;
35
+ }
36
+ declare const List: import("svelte").Component<Props, {}, "value" | "searchTerm">;
37
+ type List = ReturnType<typeof List>;
38
+ export default List;
@@ -0,0 +1 @@
1
+ export { default } from './List.svelte';
@@ -0,0 +1 @@
1
+ export { default } from './List.svelte';