@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.
- package/README.md +81 -0
- package/dist/components/Badge/Badge.svelte +80 -0
- package/dist/components/Badge/Badge.svelte.d.ts +14 -0
- package/dist/components/Badge/index.d.ts +1 -0
- package/dist/components/Badge/index.js +1 -0
- package/dist/components/Button/Button.svelte +172 -0
- package/dist/components/Button/Button.svelte.d.ts +32 -0
- package/dist/components/Button/index.d.ts +1 -0
- package/dist/components/Button/index.js +1 -0
- package/dist/components/Dialog/Dialog.svelte +101 -0
- package/dist/components/Dialog/Dialog.svelte.d.ts +18 -0
- package/dist/components/Dialog/index.d.ts +1 -0
- package/dist/components/Dialog/index.js +1 -0
- package/dist/components/ElementManager/ElementManager.svelte +397 -0
- package/dist/components/ElementManager/ElementManager.svelte.d.ts +43 -0
- package/dist/components/ElementManager/index.d.ts +1 -0
- package/dist/components/ElementManager/index.js +1 -0
- package/dist/components/EmailInput/EmailInput.svelte +47 -0
- package/dist/components/EmailInput/EmailInput.svelte.d.ts +14 -0
- package/dist/components/EmailInput/index.d.ts +1 -0
- package/dist/components/EmailInput/index.js +1 -0
- package/dist/components/Icon/Icon.svelte +74 -0
- package/dist/components/Icon/Icon.svelte.d.ts +13 -0
- package/dist/components/Icon/index.d.ts +1 -0
- package/dist/components/Icon/index.js +1 -0
- package/dist/components/Input/Input.svelte +268 -0
- package/dist/components/Input/Input.svelte.d.ts +18 -0
- package/dist/components/Input/index.d.ts +1 -0
- package/dist/components/Input/index.js +1 -0
- package/dist/components/List/List.svelte +580 -0
- package/dist/components/List/List.svelte.d.ts +38 -0
- package/dist/components/List/index.d.ts +1 -0
- package/dist/components/List/index.js +1 -0
- package/dist/components/ListItem/ListItem.svelte +175 -0
- package/dist/components/ListItem/ListItem.svelte.d.ts +24 -0
- package/dist/components/ListItem/index.d.ts +2 -0
- package/dist/components/ListItem/index.js +2 -0
- package/dist/components/ListView/ListView.svelte +463 -0
- package/dist/components/ListView/ListView.svelte.d.ts +37 -0
- package/dist/components/ListView/index.d.ts +2 -0
- package/dist/components/ListView/index.js +2 -0
- package/dist/components/NodeGraph/BaseNode.d.ts +57 -0
- package/dist/components/NodeGraph/BaseNode.js +30 -0
- package/dist/components/NodeGraph/Edge.svelte +60 -0
- package/dist/components/NodeGraph/Edge.svelte.d.ts +16 -0
- package/dist/components/NodeGraph/ExpressionEvaluator.d.ts +82 -0
- package/dist/components/NodeGraph/ExpressionEvaluator.js +152 -0
- package/dist/components/NodeGraph/Node.svelte +100 -0
- package/dist/components/NodeGraph/Node.svelte.d.ts +10 -0
- package/dist/components/NodeGraph/NodeGraph.svelte +52 -0
- package/dist/components/NodeGraph/NodeGraph.svelte.d.ts +14 -0
- package/dist/components/NodeGraph/NodeInstance.svelte.d.ts +80 -0
- package/dist/components/NodeGraph/NodeInstance.svelte.js +241 -0
- package/dist/components/NodeGraph/NodePort.svelte +75 -0
- package/dist/components/NodeGraph/NodePort.svelte.d.ts +11 -0
- package/dist/components/NodeGraph/decorators.d.ts +81 -0
- package/dist/components/NodeGraph/decorators.js +148 -0
- package/dist/components/NodeGraph/index.d.ts +9 -0
- package/dist/components/NodeGraph/index.js +9 -0
- package/dist/components/NodeGraph/types.d.ts +94 -0
- package/dist/components/NodeGraph/types.js +33 -0
- package/dist/components/NotesEditor/NotesEditor.svelte +203 -0
- package/dist/components/NotesEditor/NotesEditor.svelte.d.ts +9 -0
- package/dist/components/NotesEditor/index.d.ts +1 -0
- package/dist/components/NotesEditor/index.js +1 -0
- package/dist/components/NumberInput/NumberInput.svelte +353 -0
- package/dist/components/NumberInput/NumberInput.svelte.d.ts +26 -0
- package/dist/components/NumberInput/index.d.ts +1 -0
- package/dist/components/NumberInput/index.js +1 -0
- package/dist/components/PasswordInput/PasswordInput.svelte +41 -0
- package/dist/components/PasswordInput/PasswordInput.svelte.d.ts +13 -0
- package/dist/components/PasswordInput/index.d.ts +1 -0
- package/dist/components/PasswordInput/index.js +1 -0
- package/dist/components/Popup/index.d.ts +1 -0
- package/dist/components/Popup/index.js +1 -0
- package/dist/components/Rating/Rating.svelte +77 -0
- package/dist/components/Rating/Rating.svelte.d.ts +7 -0
- package/dist/components/Rating/index.d.ts +1 -0
- package/dist/components/Rating/index.js +1 -0
- package/dist/components/ScrollView/ScrollView.svelte +285 -0
- package/dist/components/ScrollView/ScrollView.svelte.d.ts +19 -0
- package/dist/components/ScrollView/index.d.ts +1 -0
- package/dist/components/ScrollView/index.js +1 -0
- package/dist/components/SearchInput/SearchInput.svelte +50 -0
- package/dist/components/SearchInput/SearchInput.svelte.d.ts +15 -0
- package/dist/components/SearchInput/index.d.ts +1 -0
- package/dist/components/SearchInput/index.js +1 -0
- package/dist/components/Select/Select.svelte +278 -0
- package/dist/components/Select/Select.svelte.d.ts +35 -0
- package/dist/components/Select/index.d.ts +1 -0
- package/dist/components/Select/index.js +1 -0
- package/dist/components/TextArea/TextArea.svelte +734 -0
- package/dist/components/TextArea/TextArea.svelte.d.ts +19 -0
- package/dist/components/TextArea/index.d.ts +1 -0
- package/dist/components/TextArea/index.js +1 -0
- package/dist/components/ThemeSelector/ThemeSelector.svelte +64 -0
- package/dist/components/ThemeSelector/ThemeSelector.svelte.d.ts +3 -0
- package/dist/components/ThemeSelector/index.d.ts +1 -0
- package/dist/components/ThemeSelector/index.js +1 -0
- package/dist/components/TooltipHandler/TooltipHandler.svelte +593 -0
- package/dist/components/TooltipHandler/TooltipHandler.svelte.d.ts +10 -0
- package/dist/components/TooltipHandler/index.d.ts +1 -0
- package/dist/components/TooltipHandler/index.js +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +26 -0
- package/dist/styles/sizes.d.ts +78 -0
- package/dist/styles/sizes.js +120 -0
- package/dist/styles/variants.d.ts +106 -0
- package/dist/styles/variants.js +194 -0
- package/dist/types/ManagerTypes.d.ts +42 -0
- package/dist/types/ManagerTypes.js +1 -0
- package/dist/types/sizes.d.ts +5 -0
- package/dist/types/sizes.js +1 -0
- package/dist/types/variants.d.ts +1 -0
- package/dist/types/variants.js +1 -0
- 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';
|