@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,175 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from "svelte";
3
+ import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
4
+ import type { Variant } from "../../types/variants.js";
5
+ import Icon from "../Icon/Icon.svelte";
6
+ import { getVariantClasses } from "../../styles/variants.js";
7
+
8
+ interface Props {
9
+ value: string;
10
+ label?: string;
11
+ secondaryText?: string;
12
+ icon?: IconDefinition;
13
+ prefix?: string;
14
+ suffix?: string;
15
+ variant?: Variant;
16
+ disabled?: boolean;
17
+ selected?: boolean;
18
+ focused?: boolean;
19
+ showCheckbox?: boolean;
20
+ class?: string;
21
+ children?: Snippet;
22
+ onclick?: (event: MouseEvent) => void;
23
+ onfocus?: (event: FocusEvent) => void;
24
+ onblur?: (event: FocusEvent) => void;
25
+ }
26
+
27
+ let {
28
+ value,
29
+ label,
30
+ secondaryText,
31
+ icon,
32
+ prefix,
33
+ suffix,
34
+ variant = "ghost",
35
+ disabled = false,
36
+ selected = false,
37
+ focused = false,
38
+ showCheckbox = false,
39
+ class: className = "",
40
+ children,
41
+ onclick,
42
+ onfocus,
43
+ onblur,
44
+ }: Props = $props();
45
+
46
+ // Determine background classes based on state and variant
47
+ const backgroundClasses = $derived(() => {
48
+ // Checkbox mode: no selection highlighting, only focus/hover
49
+ if (showCheckbox) {
50
+ if (focused) {
51
+ return "[background-color:var(--color-secondary)]";
52
+ }
53
+ return "bg-transparent hover:[background-color:var(--color-secondary)]";
54
+ }
55
+
56
+ // Selected state: darker secondary background
57
+ if (selected) {
58
+ return "[background-color:color-mix(in_srgb,var(--color-secondary)_80%,black)] hover:[background-color:color-mix(in_srgb,var(--color-secondary)_80%,black)]";
59
+ }
60
+
61
+ // Focused (not selected)
62
+ if (focused) {
63
+ return "[background-color:var(--color-secondary)]";
64
+ }
65
+
66
+ // Default: transparent with hover
67
+ return "bg-transparent hover:[background-color:var(--color-secondary)]";
68
+ });
69
+
70
+ const textClasses = $derived(() => {
71
+ if (disabled) {
72
+ return "[color:color-mix(in_srgb,var(--color-text)_40%,transparent)]";
73
+ }
74
+
75
+ // For selected items, check if variant needs specific text color
76
+ if (selected && variant !== "ghost") {
77
+ // Variants with solid backgrounds need white text
78
+ return "text-white";
79
+ }
80
+
81
+ return "[color:var(--color-text)]";
82
+ });
83
+
84
+ const cursorClass = $derived(
85
+ disabled ? "cursor-not-allowed" : "cursor-pointer",
86
+ );
87
+ </script>
88
+
89
+ <button
90
+ type="button"
91
+ class="w-full overflow-hidden text-left focus:outline-none flex items-center transition-colors duration-150 {backgroundClasses()} {textClasses()} {cursorClass} {className}"
92
+ style="height: var(--list-item-height, 32px); border: none; border-radius: 0;"
93
+ {onclick}
94
+ {onfocus}
95
+ {onblur}
96
+ tabindex="-1"
97
+ role="option"
98
+ aria-selected={selected}
99
+ aria-disabled={disabled}
100
+ aria-label={label || "List item"}
101
+ {disabled}
102
+ data-value={value}
103
+ >
104
+ {#if children}
105
+ {@render children()}
106
+ {:else if showCheckbox}
107
+ <div class="flex items-center gap-3 px-3 w-full">
108
+ <div class="relative flex-shrink-0 mt-1">
109
+ <input
110
+ type="checkbox"
111
+ checked={selected}
112
+ {disabled}
113
+ class="w-4 h-4 rounded [border-color:var(--color-secondary)] {selected
114
+ ? '[background-color:var(--color-primary)] [border-color:var(--color-primary)]'
115
+ : '[background-color:var(--color-background)]'} {disabled
116
+ ? 'opacity-50 cursor-not-allowed'
117
+ : 'cursor-pointer'}"
118
+ tabindex="-1"
119
+ readonly
120
+ />
121
+ {#if selected}
122
+ <svg
123
+ class="absolute inset-0 w-4 h-4 text-white pointer-events-none"
124
+ fill="currentColor"
125
+ viewBox="0 0 20 20"
126
+ >
127
+ <path
128
+ fill-rule="evenodd"
129
+ 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"
130
+ clip-rule="evenodd"
131
+ />
132
+ </svg>
133
+ {/if}
134
+ </div>
135
+ <div class="flex-1 min-w-0">
136
+ {#if secondaryText}
137
+ <div class="font-medium text-sm truncate">{label}</div>
138
+ <div
139
+ class="text-xs [color:color-mix(in_srgb,var(--color-text)_60%,transparent)] truncate"
140
+ >
141
+ {secondaryText}
142
+ </div>
143
+ {:else}
144
+ <span class="text-sm truncate block">{label}</span>
145
+ {/if}
146
+ </div>
147
+ </div>
148
+ {:else}
149
+ <div class="flex items-center gap-2 px-3 w-full min-w-0">
150
+ {#if prefix}
151
+ <span class="flex-shrink-0 text-lg">{prefix}</span>
152
+ {/if}
153
+ {#if icon}
154
+ <span class="flex-shrink-0"><Icon {icon} size="sm" /></span>
155
+ {/if}
156
+ {#if label || secondaryText}
157
+ <div class="flex-1 min-w-0">
158
+ {#if secondaryText}
159
+ <div class="font-medium text-sm truncate">{label}</div>
160
+ <div
161
+ class="text-xs [color:color-mix(in_srgb,var(--color-text)_60%,transparent)] truncate"
162
+ >
163
+ {secondaryText}
164
+ </div>
165
+ {:else}
166
+ <span class="text-sm truncate block">{label}</span>
167
+ {/if}
168
+ </div>
169
+ {/if}
170
+ {#if suffix}
171
+ <span class="flex-shrink-0 text-sm ml-auto">{suffix}</span>
172
+ {/if}
173
+ </div>
174
+ {/if}
175
+ </button>
@@ -0,0 +1,24 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { IconDefinition } from "@fortawesome/free-solid-svg-icons";
3
+ import type { Variant } from "../../types/variants.js";
4
+ interface Props {
5
+ value: string;
6
+ label?: string;
7
+ secondaryText?: string;
8
+ icon?: IconDefinition;
9
+ prefix?: string;
10
+ suffix?: string;
11
+ variant?: Variant;
12
+ disabled?: boolean;
13
+ selected?: boolean;
14
+ focused?: boolean;
15
+ showCheckbox?: boolean;
16
+ class?: string;
17
+ children?: Snippet;
18
+ onclick?: (event: MouseEvent) => void;
19
+ onfocus?: (event: FocusEvent) => void;
20
+ onblur?: (event: FocusEvent) => void;
21
+ }
22
+ declare const ListItem: import("svelte").Component<Props, {}, "">;
23
+ type ListItem = ReturnType<typeof ListItem>;
24
+ export default ListItem;
@@ -0,0 +1,2 @@
1
+ export { default } from "./ListItem.svelte";
2
+ export { default as ListItem } from "./ListItem.svelte";
@@ -0,0 +1,2 @@
1
+ export { default } from "./ListItem.svelte";
2
+ export { default as ListItem } from "./ListItem.svelte";
@@ -0,0 +1,463 @@
1
+ <script lang="ts">
2
+ import { untrack } from "svelte";
3
+ import type { Snippet } from "svelte";
4
+ import ScrollView from "../ScrollView/ScrollView.svelte";
5
+
6
+ type Option = Record<string, any> & {
7
+ value: string;
8
+ label: string;
9
+ };
10
+
11
+ interface Props {
12
+ options?: Option[];
13
+ value?: string | string[]; // Bindable selected value - string[] when multiSelect is true
14
+ multiSelect?: boolean; // Enable multi-select mode
15
+ showCheckboxes?: boolean; // Show checkboxes for selection (implies multiSelect)
16
+ maxVisibleItems?: number; // When not set, grows to fit all items
17
+ itemHeight?: number;
18
+ minHeight?: number; // Minimum height in pixels (default 32px)
19
+ class?: string;
20
+ containerClass?: string;
21
+ disabled?: boolean;
22
+ children?: Snippet;
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 | 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, // Will be auto-detected: 40px if secondaryText found, otherwise 32px
49
+ minHeight = 32, // Should match var(--ui-height) for standard UI element height
50
+ class: className = "",
51
+ containerClass = "",
52
+ disabled = false,
53
+ children,
54
+ // API integration props
55
+ url,
56
+ searchParam,
57
+ transform,
58
+ debounceMs = 300,
59
+ maxResults,
60
+ headers = {},
61
+ searchTerm = $bindable(""),
62
+ showLoading = true,
63
+ loadingText = "Loading...",
64
+ showError = true,
65
+ errorText = "Failed to load data",
66
+ emptyText = "No data",
67
+ onError,
68
+ onLoadStart,
69
+ onLoadEnd,
70
+ onSelection,
71
+ }: Props = $props();
72
+
73
+ // showCheckboxes implies multiSelect
74
+ const effectiveMultiSelect = $derived(multiSelect || showCheckboxes);
75
+
76
+ // Initialize value based on multiSelect mode
77
+ $effect(() => {
78
+ if (value === undefined) {
79
+ value = effectiveMultiSelect ? [] : "";
80
+ }
81
+ });
82
+
83
+ // Helper functions for multi-select
84
+ function isSelected(optionValue: string): boolean {
85
+ if (effectiveMultiSelect) {
86
+ return Array.isArray(value) && value.includes(optionValue);
87
+ }
88
+ return value === optionValue;
89
+ }
90
+
91
+ function toggleSelection(optionValue: string) {
92
+ if (effectiveMultiSelect && Array.isArray(value)) {
93
+ if (value.includes(optionValue)) {
94
+ value = value.filter((v) => v !== optionValue);
95
+ } else {
96
+ value = [...value, optionValue];
97
+ }
98
+ } else {
99
+ value = optionValue;
100
+ }
101
+ }
102
+
103
+ // API integration state
104
+ let isLoading = $state(false);
105
+ let hasError = $state(false);
106
+ let currentError = $state<Error | null>(null);
107
+ let apiOptions = $state<Option[]>([]);
108
+ let lastSearchTerm = $state("");
109
+ let abortController = $state<AbortController | null>(null);
110
+
111
+ // Combine static options with API options
112
+ const allOptions = $derived(() => {
113
+ if (url && apiOptions.length > 0) {
114
+ return apiOptions;
115
+ }
116
+ return options;
117
+ });
118
+
119
+ // Calculate height - grows to fit all items when maxVisibleItems is not set
120
+ const calculatedHeight = $derived(() => {
121
+ if (maxVisibleItems !== undefined) {
122
+ // Fixed height mode - use maxVisibleItems
123
+ return maxVisibleItems * effectiveItemHeight;
124
+ } else {
125
+ // Auto-grow mode - use actual item count from options or children
126
+ const opts = allOptions();
127
+ if (opts.length > 0) {
128
+ return opts.length * effectiveItemHeight;
129
+ }
130
+ // When using children (no options), use counted rendered items
131
+ if (renderedItemCount > 0) {
132
+ return renderedItemCount * effectiveItemHeight;
133
+ }
134
+ // Fallback to minHeight
135
+ return minHeight;
136
+ }
137
+ });
138
+
139
+ const maxHeight = $derived(() => {
140
+ return Math.max(calculatedHeight(), minHeight);
141
+ });
142
+
143
+ // Check if any options have description/secondaryText fields (available before rendering)
144
+ // TODO: Enable this auto-detection when options prop pattern is fully implemented
145
+ // const hasSecondaryText = $derived(() => {
146
+ // const opts = allOptions();
147
+ // return opts.some(
148
+ // (opt) =>
149
+ // opt.description !== undefined ||
150
+ // opt.secondaryText !== undefined,
151
+ // );
152
+ // });
153
+
154
+ // Determine default height: 32px for now, will auto-detect 48px when secondaryText support is added
155
+ const defaultItemHeight = $derived(32);
156
+
157
+ // Use itemHeight prop if provided, otherwise use detected default
158
+ const effectiveItemHeight = $derived(itemHeight ?? defaultItemHeight);
159
+
160
+ let listElement = $state<HTMLDivElement>();
161
+ let focusedIndex = $state(-1);
162
+ let keyboardCursor = $state(-1);
163
+ let measuredItemHeight = $state<number | null>(null);
164
+ let renderedItemCount = $state(0);
165
+
166
+ // Automatically measure item height and count from rendered items
167
+ $effect(() => {
168
+ if (listElement) {
169
+ const firstItem = listElement.querySelector('[role="option"]');
170
+ if (firstItem) {
171
+ measuredItemHeight = firstItem.getBoundingClientRect().height;
172
+ }
173
+ // Count rendered items for auto-height mode
174
+ const items = listElement.querySelectorAll('[role="option"]');
175
+ renderedItemCount = items.length;
176
+ }
177
+ });
178
+
179
+ function selectOption(optionValue: string, event: MouseEvent) {
180
+ if (disabled) return;
181
+
182
+ event.stopPropagation();
183
+
184
+ const clickedIndex = allOptions().findIndex(
185
+ (option) => option.value === optionValue,
186
+ );
187
+ if (clickedIndex >= 0) {
188
+ keyboardCursor = clickedIndex;
189
+ }
190
+
191
+ toggleSelection(optionValue);
192
+
193
+ const selectedOption = allOptions().find(
194
+ (option) => option.value === optionValue,
195
+ );
196
+ if (selectedOption && onSelection && value !== undefined) {
197
+ if (effectiveMultiSelect && Array.isArray(value)) {
198
+ const currentValue = value; // Capture to avoid undefined in closure
199
+ const selectedOptions = allOptions().filter((opt) =>
200
+ currentValue.includes(opt.value),
201
+ );
202
+ onSelection(selectedOptions);
203
+ } else {
204
+ onSelection(selectedOption);
205
+ }
206
+ }
207
+ }
208
+
209
+ function handleListFocus() {
210
+ if (keyboardCursor < 0 && allOptions().length > 0) {
211
+ keyboardCursor = 0;
212
+ }
213
+
214
+ if (keyboardCursor >= 0) {
215
+ focusedIndex = keyboardCursor;
216
+
217
+ if (listElement) {
218
+ const itemTop = keyboardCursor * effectiveItemHeight;
219
+ const itemBottom = itemTop + effectiveItemHeight;
220
+ const viewTop = listElement.scrollTop;
221
+ const currentMaxHeight = maxHeight();
222
+ const viewBottom = viewTop + currentMaxHeight;
223
+
224
+ if (itemTop < viewTop || itemBottom > viewBottom) {
225
+ listElement.scrollTop = Math.max(
226
+ 0,
227
+ itemTop - (currentMaxHeight - effectiveItemHeight) / 2,
228
+ );
229
+ }
230
+ }
231
+ } else {
232
+ focusedIndex = -1;
233
+ }
234
+ }
235
+
236
+ function handleListBlur() {
237
+ focusedIndex = -1;
238
+ }
239
+
240
+ // API fetch function
241
+ async function fetchOptions(searchQuery: string) {
242
+ if (!url) {
243
+ apiOptions = [];
244
+ return;
245
+ }
246
+
247
+ if (searchParam && !searchQuery.trim()) {
248
+ apiOptions = [];
249
+ return;
250
+ }
251
+
252
+ if (abortController) {
253
+ abortController.abort();
254
+ }
255
+
256
+ abortController = new AbortController();
257
+
258
+ try {
259
+ isLoading = true;
260
+ onLoadStart?.();
261
+
262
+ const apiUrl = new URL(url, window.location.origin);
263
+ if (searchParam && searchQuery.trim()) {
264
+ apiUrl.searchParams.set(searchParam, searchQuery);
265
+ }
266
+
267
+ const response = await fetch(apiUrl.toString(), {
268
+ signal: abortController.signal,
269
+ headers: {
270
+ "Content-Type": "application/json",
271
+ ...headers,
272
+ },
273
+ });
274
+
275
+ if (!response.ok) {
276
+ throw new Error(
277
+ `HTTP ${response.status}: ${response.statusText}`,
278
+ );
279
+ }
280
+
281
+ const data = await response.json();
282
+
283
+ let transformedOptions = transform ? transform(data) : data;
284
+
285
+ if (maxResults && transformedOptions.length > maxResults) {
286
+ transformedOptions = transformedOptions.slice(0, maxResults);
287
+ }
288
+
289
+ apiOptions = transformedOptions;
290
+ lastSearchTerm = searchQuery;
291
+ } catch (error) {
292
+ if (error instanceof Error && error.name !== "AbortError") {
293
+ console.error("ListView API fetch error:", error);
294
+ hasError = true;
295
+ currentError = error;
296
+ onError?.(error);
297
+ apiOptions = [];
298
+ }
299
+ } finally {
300
+ isLoading = false;
301
+ onLoadEnd?.();
302
+ abortController = null;
303
+ }
304
+ }
305
+
306
+ // API fetch effect for search functionality
307
+ $effect(() => {
308
+ if (url && searchParam) {
309
+ if (searchTerm.trim() && searchTerm !== lastSearchTerm) {
310
+ const timeoutId = setTimeout(() => {
311
+ fetchOptions(searchTerm);
312
+ }, debounceMs);
313
+
314
+ return () => clearTimeout(timeoutId);
315
+ } else if (!searchTerm.trim()) {
316
+ apiOptions = [];
317
+ }
318
+ }
319
+ });
320
+
321
+ // Initial API fetch for static data (no search) - run once on mount
322
+ $effect(() => {
323
+ if (url && !searchParam) {
324
+ untrack(() => fetchOptions(""));
325
+ }
326
+ });
327
+
328
+ // Clear error state when search term changes
329
+ $effect(() => {
330
+ if (searchTerm !== lastSearchTerm) {
331
+ hasError = false;
332
+ currentError = null;
333
+ }
334
+ });
335
+
336
+ // Handle keyboard navigation within list
337
+ function handleListKeydown(event: KeyboardEvent) {
338
+ if (disabled) return;
339
+
340
+ if (event.key === "Tab") {
341
+ return;
342
+ }
343
+
344
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
345
+ event.preventDefault();
346
+ event.stopPropagation();
347
+
348
+ if (focusedIndex === -1) {
349
+ const startIndex = keyboardCursor >= 0 ? keyboardCursor : 0;
350
+
351
+ if (event.key === "ArrowDown") {
352
+ focusedIndex = Math.min(
353
+ startIndex + 1,
354
+ allOptions().length - 1,
355
+ );
356
+ } else {
357
+ focusedIndex = Math.max(startIndex - 1, 0);
358
+ }
359
+ } else {
360
+ if (event.key === "ArrowDown") {
361
+ focusedIndex = Math.min(
362
+ focusedIndex + 1,
363
+ allOptions().length - 1,
364
+ );
365
+ } else {
366
+ focusedIndex = Math.max(focusedIndex - 1, 0);
367
+ }
368
+ }
369
+
370
+ keyboardCursor = focusedIndex;
371
+
372
+ if (listElement && maxVisibleItems !== undefined) {
373
+ const itemTop = focusedIndex * itemHeight;
374
+ const itemBottom = itemTop + itemHeight;
375
+ const viewTop = listElement.scrollTop;
376
+ const currentMaxHeight = maxHeight();
377
+ const viewBottom = viewTop + currentMaxHeight;
378
+
379
+ if (itemTop < viewTop) {
380
+ listElement.scrollTop = itemTop;
381
+ } else if (itemBottom > viewBottom) {
382
+ listElement.scrollTop = itemBottom - currentMaxHeight;
383
+ }
384
+ }
385
+ } else if (event.key === " ") {
386
+ event.preventDefault();
387
+ event.stopPropagation();
388
+ if (focusedIndex >= 0 && focusedIndex < allOptions().length) {
389
+ const selectedOption = allOptions()[focusedIndex];
390
+
391
+ toggleSelection(selectedOption.value);
392
+
393
+ if (onSelection && value !== undefined) {
394
+ if (effectiveMultiSelect && Array.isArray(value)) {
395
+ const currentValue = value; // Capture to avoid undefined in closure
396
+ const selectedOptions = allOptions().filter((opt) =>
397
+ currentValue.includes(opt.value),
398
+ );
399
+ onSelection(selectedOptions);
400
+ } else {
401
+ onSelection(selectedOption);
402
+ }
403
+ }
404
+ }
405
+ }
406
+ }
407
+ </script>
408
+
409
+ <ScrollView
410
+ class={className}
411
+ style="--list-item-height: {effectiveItemHeight}px; height: {maxHeight()}px;"
412
+ {disabled}
413
+ showScrollbar={maxVisibleItems !== undefined}
414
+ bind:view={listElement}
415
+ >
416
+ <div
417
+ class="flex flex-col"
418
+ onkeydown={handleListKeydown}
419
+ onfocus={handleListFocus}
420
+ onblur={handleListBlur}
421
+ tabindex={disabled ? -1 : 0}
422
+ role="listbox"
423
+ aria-label="List of options"
424
+ aria-busy={isLoading}
425
+ aria-disabled={disabled}
426
+ aria-multiselectable={effectiveMultiSelect}
427
+ >
428
+ {#if isLoading && showLoading}
429
+ <div
430
+ class="flex items-center justify-center py-4 [color:var(--color-secondary)]"
431
+ >
432
+ <div
433
+ class="animate-spin rounded-full h-4 w-4 border-b-2 [border-color:var(--color-secondary)] mr-2"
434
+ ></div>
435
+ {loadingText}
436
+ </div>
437
+ {:else if hasError && showError}
438
+ <div
439
+ class="flex items-center justify-center py-4 [color:var(--color-danger)]"
440
+ >
441
+ <span class="text-sm [color:var(--color-danger)]"
442
+ >{errorText}</span
443
+ >
444
+ </div>
445
+ {:else if children}
446
+ {@render children()}
447
+ {:else if allOptions().length === 0}
448
+ <div
449
+ class="flex items-center justify-center [color:var(--color-secondary)] select-none"
450
+ style="height: {effectiveItemHeight}px;"
451
+ >
452
+ <span class="px-4 text-sm [color:var(--color-secondary)]"
453
+ >{emptyText}</span
454
+ >
455
+ </div>
456
+ {:else}
457
+ <div class="text-sm [color:var(--color-secondary)] px-4 py-2">
458
+ No children provided. Use ListView with ListItem components or
459
+ provide a children snippet.
460
+ </div>
461
+ {/if}
462
+ </div>
463
+ </ScrollView>