@svadmin/ui 0.88.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/dist/app.css +4 -0
  2. package/dist/app.theme.css +4 -0
  3. package/dist/component-registry.svelte.d.ts +2 -2
  4. package/dist/components/AboutSettings.svelte +23 -4
  5. package/dist/components/AdminApp.svelte +6 -4
  6. package/dist/components/AdminApp.svelte.d.ts +3 -3
  7. package/dist/components/ApiSettings.svelte +43 -27
  8. package/dist/components/ApprovalActionCard.svelte +8 -9
  9. package/dist/components/ApprovalCenter.svelte +24 -5
  10. package/dist/components/AuditLogViewer.svelte +2 -2
  11. package/dist/components/AutoComplete.svelte +610 -0
  12. package/dist/components/AutoComplete.svelte.d.ts +25 -0
  13. package/dist/components/AutoForm.svelte +263 -18
  14. package/dist/components/AutoForm.svelte.d.ts +19 -2
  15. package/dist/components/AutoTable.svelte +135 -35
  16. package/dist/components/AutoTable.svelte.d.ts +8 -0
  17. package/dist/components/BatchActionBar.svelte +42 -14
  18. package/dist/components/BatchActionBar.svelte.d.ts +5 -0
  19. package/dist/components/BoundRecordDetailDrawer.svelte +5 -3
  20. package/dist/components/CancelTaskButton.svelte +14 -1
  21. package/dist/components/Cascader.svelte +412 -53
  22. package/dist/components/Cascader.svelte.d.ts +7 -1
  23. package/dist/components/ColorPicker.svelte +1 -1
  24. package/dist/components/ColorPicker.svelte.d.ts +1 -1
  25. package/dist/components/ComboboxField.svelte +270 -34
  26. package/dist/components/ComboboxField.svelte.d.ts +4 -0
  27. package/dist/components/CommandPalette.svelte +4 -4
  28. package/dist/components/DashboardView.svelte +73 -7
  29. package/dist/components/DashboardView.svelte.d.ts +3 -1
  30. package/dist/components/DateRangeInput.svelte +8 -1
  31. package/dist/components/DetailWorkspaceAsyncSection.svelte +107 -0
  32. package/dist/components/DetailWorkspaceAsyncSection.svelte.d.ts +36 -0
  33. package/dist/components/DetailWorkspaceSections.svelte +233 -0
  34. package/dist/components/DetailWorkspaceSections.svelte.d.ts +9 -0
  35. package/dist/components/DrawerForm.svelte +78 -9
  36. package/dist/components/DrawerForm.svelte.d.ts +3 -3
  37. package/dist/components/ErrorBoundary.svelte +1 -1
  38. package/dist/components/ErrorComponent.svelte +19 -6
  39. package/dist/components/ErrorComponent.svelte.d.ts +1 -0
  40. package/dist/components/ErrorSummary.svelte +9 -4
  41. package/dist/components/FieldRenderer.svelte +203 -90
  42. package/dist/components/FieldRenderer.svelte.d.ts +16 -1
  43. package/dist/components/FileUpload.svelte +116 -51
  44. package/dist/components/FileUpload.svelte.d.ts +7 -27
  45. package/dist/components/FileUploadField.svelte +170 -0
  46. package/dist/components/FileUploadField.svelte.d.ts +21 -0
  47. package/dist/components/FilterBuilder.svelte +13 -7
  48. package/dist/components/FilterCollectionInput.svelte +9 -7
  49. package/dist/components/FilterCollectionInput.svelte.d.ts +3 -0
  50. package/dist/components/ForgotPasswordPage.svelte +1 -1
  51. package/dist/components/Header.svelte +1 -1
  52. package/dist/components/ImportWizard.svelte +197 -28
  53. package/dist/components/ImportWizard.svelte.d.ts +5 -1
  54. package/dist/components/InferencerPanel.svelte +4 -2
  55. package/dist/components/InfiniteList.svelte +52 -9
  56. package/dist/components/JsonSchemaForm.svelte +237 -42
  57. package/dist/components/JsonSchemaForm.svelte.d.ts +10 -3
  58. package/dist/components/KanbanBoard.svelte +43 -7
  59. package/dist/components/KanbanBoard.svelte.d.ts +2 -2
  60. package/dist/components/Layout.svelte +1 -1
  61. package/dist/components/LoginPage.svelte +1 -1
  62. package/dist/components/MetricStrip.svelte +8 -3
  63. package/dist/components/MetricStrip.svelte.d.ts +1 -0
  64. package/dist/components/ModalForm.svelte +85 -9
  65. package/dist/components/ModalForm.svelte.d.ts +3 -3
  66. package/dist/components/MoneyInput.svelte +5 -3
  67. package/dist/components/MoneyInput.svelte.d.ts +4 -2
  68. package/dist/components/MultiTabKeepAlive.svelte +502 -43
  69. package/dist/components/MultiTabKeepAlive.svelte.d.ts +40 -6
  70. package/dist/components/NotificationsSettings.svelte +10 -10
  71. package/dist/components/NumberInput.svelte +5 -3
  72. package/dist/components/NumberInput.svelte.d.ts +4 -2
  73. package/dist/components/NumericInput.svelte +277 -25
  74. package/dist/components/NumericInput.svelte.d.ts +3 -1
  75. package/dist/components/PercentInput.svelte +5 -3
  76. package/dist/components/PercentInput.svelte.d.ts +4 -2
  77. package/dist/components/PermissionMatrix.svelte +44 -8
  78. package/dist/components/PermissionMatrix.svelte.d.ts +1 -0
  79. package/dist/components/ProfilePage.svelte +38 -13
  80. package/dist/components/RadioGroup.svelte +203 -4
  81. package/dist/components/RadioGroup.svelte.d.ts +1 -1
  82. package/dist/components/Rate.svelte +3 -3
  83. package/dist/components/Rate.svelte.d.ts +2 -2
  84. package/dist/components/RecordDetailDrawer.svelte +1 -1
  85. package/dist/components/RecordDetailDrawer.svelte.d.ts +1 -1
  86. package/dist/components/RegisterPage.svelte +1 -1
  87. package/dist/components/RelationPicker.svelte +39 -11
  88. package/dist/components/ResourceAccessGuard.svelte +7 -2
  89. package/dist/components/RetryTaskButton.svelte +14 -1
  90. package/dist/components/RolesSettings.svelte +74 -28
  91. package/dist/components/SecuritySettings.svelte +66 -51
  92. package/dist/components/SegmentedControl.svelte +4 -2
  93. package/dist/components/SegmentedControl.svelte.d.ts +1 -1
  94. package/dist/components/Sidebar.svelte +1 -1
  95. package/dist/components/StepsForm.svelte +60 -10
  96. package/dist/components/StepsForm.svelte.d.ts +4 -2
  97. package/dist/components/SvadminLogo.svelte +1 -0
  98. package/dist/components/SvarDataGrid.svelte +42 -24
  99. package/dist/components/SvarDataGrid.svelte.d.ts +10 -10
  100. package/dist/components/SvarResourceRead.svelte +2 -0
  101. package/dist/components/SvarResourceTableView.svelte +4 -2
  102. package/dist/components/TagsInput.svelte +3 -3
  103. package/dist/components/TagsInput.svelte.d.ts +2 -2
  104. package/dist/components/TaskDetails.svelte +22 -16
  105. package/dist/components/TaskDetails.svelte.d.ts +1 -1
  106. package/dist/components/TaskList.svelte +57 -15
  107. package/dist/components/TaskQueueDrawer.svelte +54 -34
  108. package/dist/components/Transfer.svelte +67 -5
  109. package/dist/components/Transfer.svelte.d.ts +4 -0
  110. package/dist/components/TreeSelect.svelte +337 -77
  111. package/dist/components/TreeSelect.svelte.d.ts +4 -0
  112. package/dist/components/TreeTable.svelte +6 -1
  113. package/dist/components/TwoFactorAuthPage.svelte +8 -4
  114. package/dist/components/UpdatePasswordPage.svelte +1 -1
  115. package/dist/components/VersionDiffViewer.svelte +9 -7
  116. package/dist/components/account/CompanyProfilePage.svelte +13 -12
  117. package/dist/components/account/MemberDirectory.svelte +14 -10
  118. package/dist/components/account/SettingsEnterprisePage.svelte +18 -18
  119. package/dist/components/content/AuditTimeline.svelte +8 -6
  120. package/dist/components/content/DataState.svelte +1 -1
  121. package/dist/components/content/FileList.svelte +44 -13
  122. package/dist/components/content/FileList.svelte.d.ts +4 -2
  123. package/dist/components/content/FileListRow.svelte +87 -0
  124. package/dist/components/content/FileListRow.svelte.d.ts +11 -0
  125. package/dist/components/content/ImportDropzone.svelte +109 -12
  126. package/dist/components/content/ImportDropzone.svelte.d.ts +1 -0
  127. package/dist/components/content/IntegrationCard.svelte +3 -3
  128. package/dist/components/content/IntegrationCard.svelte.d.ts +2 -0
  129. package/dist/components/content/MediaThumbnail.svelte +6 -2
  130. package/dist/components/detail-workspace.d.ts +60 -0
  131. package/dist/components/detail-workspace.js +3 -0
  132. package/dist/components/enterprise/json-schema-form.d.ts +1 -1
  133. package/dist/components/enterprise/recursive-schema-form.d.ts +15 -1
  134. package/dist/components/enterprise/recursive-schema-form.js +288 -14
  135. package/dist/components/enterprise/schema-form-references.d.ts +14 -0
  136. package/dist/components/enterprise/schema-form-references.js +316 -0
  137. package/dist/components/fields/ArrayField.svelte +178 -14
  138. package/dist/components/fields/ArrayField.svelte.d.ts +17 -3
  139. package/dist/components/fields/array-field-metadata.d.ts +4 -0
  140. package/dist/components/fields/array-field-metadata.js +36 -0
  141. package/dist/components/file-upload-reference.d.ts +11 -0
  142. package/dist/components/file-upload-reference.js +50 -0
  143. package/dist/components/file-upload-types.d.ts +32 -0
  144. package/dist/components/file-upload-types.js +1 -0
  145. package/dist/components/form-contract.upload-type-fixture.svelte +108 -0
  146. package/dist/components/form-contract.upload-type-fixture.svelte.d.ts +18 -0
  147. package/dist/components/json-schema-form-state.d.ts +19 -0
  148. package/dist/components/list-deletion.svelte.js +6 -1
  149. package/dist/components/list-state.svelte.d.ts +12 -1
  150. package/dist/components/list-state.svelte.js +56 -16
  151. package/dist/components/numeric-format.d.ts +44 -0
  152. package/dist/components/numeric-format.js +198 -0
  153. package/dist/components/record-detail.svelte.js +2 -0
  154. package/dist/components/saved-list-views.d.ts +9 -2
  155. package/dist/components/saved-list-views.js +42 -11
  156. package/dist/components/saved-list-views.svelte.d.ts +3 -0
  157. package/dist/components/saved-list-views.svelte.js +56 -7
  158. package/dist/components/task-action.svelte.d.ts +1 -0
  159. package/dist/components/task-action.svelte.js +55 -3
  160. package/dist/components/task-utils.d.ts +1 -0
  161. package/dist/components/task-utils.js +18 -0
  162. package/dist/components/ui/input/input.svelte +1 -1
  163. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  164. package/dist/components/ui/sheet/sheet.svelte +4 -1
  165. package/dist/components/version-history.d.ts +14 -0
  166. package/dist/components/version-history.js +32 -0
  167. package/dist/components/workspace-tab-state.d.ts +78 -0
  168. package/dist/components/workspace-tab-state.js +587 -0
  169. package/dist/index.d.ts +13 -1
  170. package/dist/index.js +4 -0
  171. package/package.json +5 -5
@@ -0,0 +1,610 @@
1
+ <script lang="ts">
2
+ import { definedReactiveOptions } from '@svadmin/core/options';
3
+ import { useTranslation } from '@svadmin/core/i18n';
4
+ import {
5
+ captureAdminContext, captureAuthSession, useResourceContract, useSelect,
6
+ } from '@svadmin/core';
7
+ import { onDestroy, untrack } from 'svelte';
8
+ import type { BaseRecord, Filter } from '@svadmin/core';
9
+ import { RotateCw, X } from '@lucide/svelte';
10
+
11
+ interface Props {
12
+ resource: string;
13
+ value?: string | number | null;
14
+ onchange?: (value: string | number | null) => void;
15
+ optionLabel?: string;
16
+ optionValue?: string;
17
+ placeholder?: string;
18
+ onSearch?: (value: string) => Filter[];
19
+ fetchSize?: number;
20
+ requestTimeout?: number;
21
+ debounce?: number;
22
+ virtualized?: boolean;
23
+ itemHeight?: number;
24
+ viewportHeight?: number;
25
+ disabled?: boolean;
26
+ id?: string;
27
+ class?: string;
28
+ 'aria-label'?: string;
29
+ 'aria-describedby'?: string;
30
+ 'aria-invalid'?: boolean | 'true' | 'false';
31
+ }
32
+
33
+ let {
34
+ resource,
35
+ value = null,
36
+ onchange,
37
+ optionLabel = 'title',
38
+ optionValue = 'id',
39
+ placeholder,
40
+ onSearch,
41
+ fetchSize = 50,
42
+ requestTimeout,
43
+ debounce = 300,
44
+ virtualized = true,
45
+ itemHeight = 36,
46
+ viewportHeight = 280,
47
+ disabled = false,
48
+ id,
49
+ class: className,
50
+ 'aria-label': ariaLabel,
51
+ 'aria-describedby': ariaDescribedby,
52
+ 'aria-invalid': ariaInvalid,
53
+ }: Props = $props();
54
+
55
+ const i18n = useTranslation();
56
+ const instanceId = $props.id();
57
+ const inputId = $derived(id ?? `${instanceId}-input`);
58
+ const listboxId = $derived(`${instanceId}-listbox`);
59
+ const optionId = (index: number) => `${instanceId}-option-${index}`;
60
+
61
+ let open = $state(false);
62
+ let inputValue = $state('');
63
+ let appliedSearch = $state('');
64
+ // null means the input mirrors the selected label; a string means the user is drafting a search.
65
+ let searchDraft = $state<string | null>(null);
66
+ const searchPending = $derived(searchDraft !== null && searchDraft !== appliedSearch);
67
+ let page = $state(1);
68
+ let activeIndex = $state(-1);
69
+ let listScrollTop = $state(0);
70
+ let inputElement = $state<HTMLInputElement | null>(null);
71
+ let listElement = $state<HTMLElement | null>(null);
72
+ let rootElement = $state<HTMLElement | null>(null);
73
+ let timer: ReturnType<typeof setTimeout> | undefined;
74
+ onDestroy(() => clearTimeout(timer));
75
+
76
+ const normalizedFetchSize = $derived(
77
+ Number.isSafeInteger(fetchSize) && fetchSize >= 10 && fetchSize <= 200 ? fetchSize : 50,
78
+ );
79
+ const normalizedDebounce = $derived(
80
+ typeof debounce === 'number' && Number.isSafeInteger(debounce)
81
+ && debounce >= 0 && debounce <= 2_147_483_647 ? debounce : 300,
82
+ );
83
+ const normalizedItemHeight = $derived(
84
+ Number.isSafeInteger(itemHeight) && itemHeight >= 32 && itemHeight <= 100 ? itemHeight : 36,
85
+ );
86
+ const normalizedViewportHeight = $derived(
87
+ Number.isSafeInteger(viewportHeight) && viewportHeight >= 120 && viewportHeight <= 800 ? viewportHeight : 280,
88
+ );
89
+
90
+ const binding = useResourceContract(() => resource);
91
+ const context = captureAdminContext();
92
+ const sourceScope = $derived({
93
+ contract: binding.resource, provider: context.providers?.[binding.dataProviderName],
94
+ meta: JSON.stringify(binding.meta), tenant: context.tenantCacheKey?.__svadminTenant,
95
+ auth: context.authProvider, session: captureAuthSession(context.authProvider).cacheKey,
96
+ access: context.accessControlProvider, router: context.routerProvider,
97
+ optionLabel, optionValue, onSearch, normalizedFetchSize, requestTimeout: String(requestTimeout),
98
+ });
99
+ function sameSourceScope(left: typeof sourceScope, right: typeof sourceScope): boolean {
100
+ return (Object.keys(left) as (keyof typeof sourceScope)[]).every(key => left[key] === right[key]);
101
+ }
102
+ type Option = { value: string | number; label: string };
103
+ let loaded = $state.raw<{ scope: typeof sourceScope; search: string; options: Option[] }>();
104
+ let observedSourceScope = untrack(() => sourceScope);
105
+
106
+ const defaultSearch = $derived.by(() => {
107
+ const field = optionLabel;
108
+ return (v: string): Filter[] => v ? [{ field, operator: 'contains', value: v }] : [];
109
+ });
110
+ const searchFilters = $derived.by(() => {
111
+ try {
112
+ return {
113
+ filters: appliedSearch ? (onSearch ?? defaultSearch)(appliedSearch) : [],
114
+ invalid: false,
115
+ };
116
+ } catch {
117
+ return { filters: [], invalid: true };
118
+ }
119
+ });
120
+ const select = useSelect(definedReactiveOptions({
121
+ get resource() { return binding.resource; },
122
+ get dataProviderName() { return binding.dataProviderName; },
123
+ get optionLabel() { return optionLabel; },
124
+ get optionValue() {
125
+ const field = optionValue;
126
+ return (row: BaseRecord) => {
127
+ const candidate = row[field];
128
+ if (typeof candidate === 'string' || (typeof candidate === 'number' && Number.isFinite(candidate))) return candidate;
129
+ throw new TypeError('Invalid autocomplete value field');
130
+ };
131
+ },
132
+ get projectionKey() { return `autocomplete:${optionLabel}:${optionValue}`; },
133
+ get fetchSize() { return normalizedFetchSize; },
134
+ get requestTimeout() { return requestTimeout; },
135
+ get pagination() { return { current: page, pageSize: normalizedFetchSize }; },
136
+ get defaultValue() {
137
+ return value === null || value === undefined || value === '' ? [] : [value];
138
+ },
139
+ get filters() { return searchFilters.filters; },
140
+ get queryOptions() { return { enabled: !searchFilters.invalid }; },
141
+ }));
142
+ onDestroy(() => select.cancel());
143
+
144
+ $effect.pre(() => {
145
+ const nextScope = sourceScope;
146
+ const previousScope = untrack(() => observedSourceScope);
147
+ if (sameSourceScope(previousScope, nextScope)) return;
148
+ observedSourceScope = nextScope;
149
+ clearTimeout(timer);
150
+ open = false;
151
+ inputValue = appliedSearch = '';
152
+ searchDraft = null;
153
+ page = 1;
154
+ activeIndex = -1;
155
+ loaded = undefined;
156
+ listScrollTop = 0;
157
+ select.cancel();
158
+ });
159
+
160
+ $effect(() => {
161
+ const data = select.query.data;
162
+ if (!data || select.query.isFetching || select.isError || searchFilters.invalid) return;
163
+ const scope = sourceScope;
164
+ const search = appliedSearch;
165
+ const previous = untrack(() => loaded);
166
+ const options = new Map<string | number, Option>(
167
+ page > 1 && previous && sameSourceScope(previous.scope, scope) && previous.search === search
168
+ ? previous.options.map(option => [option.value, option]) : [],
169
+ );
170
+ for (const option of data.options) options.set(option.value, option);
171
+ loaded = { scope, search, options: [...options.values()] };
172
+ });
173
+
174
+ const availableOptions = $derived.by(() => {
175
+ if (select.isError || searchFilters.invalid) return [];
176
+ const options = new Map<string | number, Option>(
177
+ loaded && sameSourceScope(loaded.scope, sourceScope) && loaded.search === appliedSearch
178
+ ? loaded.options.map(option => [option.value, option]) : [],
179
+ );
180
+ for (const option of select.options) options.set(option.value, option);
181
+ return [...options.values()];
182
+ });
183
+ const selectionOptions = $derived.by(() => {
184
+ const options = new Map<string | number, Option>();
185
+ if (select.defaultValueQuery.isError) return options;
186
+ if (loaded && sameSourceScope(loaded.scope, sourceScope)) {
187
+ for (const option of loaded.options) options.set(option.value, option);
188
+ }
189
+ for (const option of select.options) options.set(option.value, option);
190
+ return options;
191
+ });
192
+ const selectedLabel = $derived(value === null || value === undefined
193
+ ? '' : selectionOptions.get(value)?.label ?? '');
194
+ const totalOptions = $derived(select.query.data?.total ?? availableOptions.length);
195
+ const hasMore = $derived(open && (totalOptions > page * normalizedFetchSize
196
+ || (select.query.isFetching && page > 1)) && !select.isError && !searchFilters.invalid
197
+ && !searchPending);
198
+ const invalidOptions = $derived(select.isError || searchFilters.invalid);
199
+
200
+ const shouldVirtualize = $derived(virtualized && availableOptions.length >= 200);
201
+ const virtualTotalHeight = $derived(availableOptions.length * normalizedItemHeight);
202
+ const virtualMaxScroll = $derived(Math.max(0, virtualTotalHeight - normalizedViewportHeight));
203
+ const clampedListScrollTop = $derived(Math.max(0, Math.min(listScrollTop, virtualMaxScroll)));
204
+ const virtualStart = $derived(Math.max(0, Math.floor(clampedListScrollTop / normalizedItemHeight) - 5));
205
+ const virtualEnd = $derived(Math.min(availableOptions.length,
206
+ Math.ceil((clampedListScrollTop + normalizedViewportHeight) / normalizedItemHeight) + 5));
207
+ const renderedOptions = $derived(shouldVirtualize
208
+ ? availableOptions.slice(virtualStart, virtualEnd) : availableOptions);
209
+ const activeOptionId = $derived(open && activeIndex >= 0 && availableOptions[activeIndex]
210
+ && (!shouldVirtualize || (activeIndex >= virtualStart && activeIndex < virtualEnd))
211
+ ? optionId(activeIndex) : undefined);
212
+
213
+ let observedValue = untrack(() => value);
214
+ $effect(() => {
215
+ const currentValue = value;
216
+ if (currentValue !== observedValue) {
217
+ observedValue = currentValue;
218
+ if (open) {
219
+ inputValue = selectedLabel;
220
+ appliedSearch = '';
221
+ searchDraft = null;
222
+ page = 1;
223
+ loaded = undefined;
224
+ activeIndex = -1;
225
+ listScrollTop = 0;
226
+ }
227
+ }
228
+ if (!open && inputValue !== selectedLabel) inputValue = selectedLabel;
229
+ });
230
+ $effect(() => {
231
+ const count = availableOptions.length;
232
+ if (activeIndex >= count) activeIndex = count > 0 ? count - 1 : -1;
233
+ });
234
+ $effect(() => {
235
+ if (!shouldVirtualize || activeIndex < 0) return;
236
+ if (activeIndex < virtualStart || activeIndex >= virtualEnd) activeIndex = -1;
237
+ });
238
+ $effect.pre(() => {
239
+ if (disabled) closeMenu(false);
240
+ });
241
+ $effect(() => {
242
+ if (listScrollTop !== clampedListScrollTop) {
243
+ listScrollTop = clampedListScrollTop;
244
+ if (listElement) listElement.scrollTop = clampedListScrollTop;
245
+ }
246
+ });
247
+
248
+ function closeMenu(restore: boolean): void {
249
+ open = false;
250
+ activeIndex = -1;
251
+ searchDraft = null;
252
+ clearTimeout(timer);
253
+ timer = undefined;
254
+ select.cancel();
255
+ if (restore) inputValue = selectedLabel;
256
+ }
257
+
258
+ function outsideInteractions(node: HTMLElement) {
259
+ const handler = (event: MouseEvent) => {
260
+ if (event.target instanceof Node && !node.contains(event.target) && open) closeMenu(true);
261
+ };
262
+ document.addEventListener('click', handler, true);
263
+ return { destroy() { document.removeEventListener('click', handler, true); } };
264
+ }
265
+
266
+ function handleFocusOut(event: FocusEvent): void {
267
+ const next = event.relatedTarget;
268
+ if (next instanceof Node && event.currentTarget instanceof HTMLElement
269
+ && !event.currentTarget.contains(next)) closeMenu(true);
270
+ }
271
+
272
+ function resetSearch(): void {
273
+ appliedSearch = '';
274
+ searchDraft = null;
275
+ page = 1;
276
+ loaded = undefined;
277
+ activeIndex = -1;
278
+ listScrollTop = 0;
279
+ if (listElement) listElement.scrollTop = 0;
280
+ }
281
+
282
+ function openMenu(): void {
283
+ if (disabled) return;
284
+ if (!open) {
285
+ resetSearch();
286
+ inputValue = selectedLabel;
287
+ open = true;
288
+ }
289
+ activeIndex = -1;
290
+ }
291
+
292
+ function applySearch(value: string): void {
293
+ appliedSearch = value;
294
+ page = 1;
295
+ loaded = undefined;
296
+ activeIndex = -1;
297
+ listScrollTop = 0;
298
+ if (listElement) listElement.scrollTop = 0;
299
+ }
300
+
301
+ function handleInput(event: Event): void {
302
+ if (!(event.currentTarget instanceof HTMLInputElement)) return;
303
+ const next = event.currentTarget.value;
304
+ openMenu();
305
+ inputValue = next;
306
+ searchDraft = next;
307
+ clearTimeout(timer);
308
+ const scope = sourceScope;
309
+ const apply = () => {
310
+ if (!open || inputValue !== next || !sameSourceScope(scope, sourceScope) || disabled) return;
311
+ applySearch(next);
312
+ };
313
+ if (!next || normalizedDebounce === 0) apply();
314
+ else timer = setTimeout(apply, normalizedDebounce);
315
+ }
316
+
317
+ function pick(option: Option): void {
318
+ if (disabled || invalidOptions || select.isFetching || searchPending) return;
319
+ if (!availableOptions.some(candidate => candidate.value === option.value)) return;
320
+ inputValue = option.label;
321
+ closeMenu(false);
322
+ onchange?.(option.value);
323
+ }
324
+
325
+ function revealActive(): void {
326
+ if (!shouldVirtualize || activeIndex < 0) return;
327
+ const top = activeIndex * normalizedItemHeight;
328
+ const bottom = top + normalizedItemHeight;
329
+ if (top < clampedListScrollTop) listScrollTop = top;
330
+ else if (bottom > clampedListScrollTop + normalizedViewportHeight) {
331
+ listScrollTop = bottom - normalizedViewportHeight;
332
+ }
333
+ if (listElement) listElement.scrollTop = listScrollTop;
334
+ }
335
+
336
+ function moveActive(delta: number): void {
337
+ if (!open) openMenu();
338
+ const count = availableOptions.length;
339
+ if (!count) return;
340
+ const start = activeIndex < 0 ? (delta > 0 ? -1 : count) : activeIndex;
341
+ activeIndex = (start + delta + count) % count;
342
+ revealActive();
343
+ }
344
+
345
+ function handleKeydown(event: KeyboardEvent): void {
346
+ if (event.defaultPrevented || disabled || event.isComposing) return;
347
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
348
+ event.preventDefault();
349
+ moveActive(event.key === 'ArrowDown' ? 1 : -1);
350
+ return;
351
+ }
352
+ if ((event.key === 'Home' || event.key === 'End') && open && activeIndex >= 0
353
+ && !event.altKey && !event.ctrlKey && !event.metaKey) {
354
+ const count = availableOptions.length;
355
+ if (!count) return;
356
+ event.preventDefault();
357
+ activeIndex = event.key === 'Home' ? 0 : count - 1;
358
+ revealActive();
359
+ return;
360
+ }
361
+ if (event.key === 'Enter') {
362
+ if (open && activeIndex >= 0) {
363
+ const option = availableOptions[activeIndex];
364
+ if (option) {
365
+ event.preventDefault();
366
+ pick(option);
367
+ }
368
+ }
369
+ return;
370
+ }
371
+ if (event.key === 'Escape') {
372
+ if (!open) return;
373
+ event.preventDefault();
374
+ event.stopPropagation();
375
+ closeMenu(true);
376
+ return;
377
+ }
378
+ if (event.key === 'Tab') closeMenu(true);
379
+ }
380
+
381
+ function loadMore(): void {
382
+ if (disabled || select.isFetching || !hasMore) return;
383
+ page += 1;
384
+ }
385
+ </script>
386
+
387
+ <div class="autocomplete {className ?? ''}" bind:this={rootElement} use:outsideInteractions onfocusout={handleFocusOut}>
388
+ <input
389
+ bind:this={inputElement}
390
+ id={inputId}
391
+ type="text"
392
+ role="combobox"
393
+ autocomplete="off"
394
+ spellcheck="false"
395
+ aria-autocomplete="list"
396
+ aria-expanded={open}
397
+ aria-controls={open ? listboxId : undefined}
398
+ aria-activedescendant={activeOptionId}
399
+ aria-label={ariaLabel}
400
+ aria-describedby={ariaDescribedby}
401
+ aria-invalid={invalidOptions || ariaInvalid || undefined}
402
+ aria-busy={select.isFetching}
403
+ placeholder={placeholder ?? i18n.t('field.selectPlaceholder')}
404
+ value={inputValue}
405
+ {disabled}
406
+ oninput={handleInput}
407
+ onfocus={() => openMenu()}
408
+ onclick={() => openMenu()}
409
+ onkeydown={handleKeydown}
410
+ />
411
+ {#if value !== null && value !== undefined}
412
+ <button
413
+ type="button"
414
+ class="autocomplete-clear"
415
+ title={i18n.t('common.clear')}
416
+ aria-label={i18n.t('common.clear')}
417
+ onclick={(event: MouseEvent) => {
418
+ event.stopPropagation();
419
+ inputValue = '';
420
+ closeMenu(false);
421
+ onchange?.(null);
422
+ }}
423
+ >
424
+ <X aria-hidden="true" />
425
+ </button>
426
+ {/if}
427
+ {#if open && !disabled}
428
+ <div class="autocomplete-popup">
429
+ <div
430
+ bind:this={listElement}
431
+ id={listboxId}
432
+ role="listbox"
433
+ tabindex={-1}
434
+ style={shouldVirtualize
435
+ ? `height:${normalizedViewportHeight}px;max-height:${normalizedViewportHeight}px;overflow-y:auto;` : undefined}
436
+ onscroll={(event: Event) => {
437
+ if (event.currentTarget instanceof HTMLElement) listScrollTop = event.currentTarget.scrollTop;
438
+ }}
439
+ >
440
+ {#if select.isLoading}
441
+ <div role="status" class="autocomplete-state">{i18n.t('common.loading')}</div>
442
+ {:else if invalidOptions}
443
+ <div role="alert" class="autocomplete-error">
444
+ <span>{i18n.t('select.loadFailed')}</span>
445
+ <button type="button" disabled={select.isFetching}
446
+ title={i18n.t('common.retry')} aria-label={i18n.t('common.retry')} onclick={() => select.refetch()}>
447
+ <RotateCw aria-hidden="true" />
448
+ </button>
449
+ </div>
450
+ {:else if !availableOptions.length}
451
+ <div class="autocomplete-state">{i18n.t('select.noResults')}</div>
452
+ {:else if shouldVirtualize}
453
+ <div style={`height:${virtualTotalHeight}px;position:relative;`}>
454
+ <div style={`position:absolute;top:${virtualStart * normalizedItemHeight}px;left:0;right:0;`}>
455
+ {#each renderedOptions as option, offset (`${typeof option.value}:${option.value}`)}
456
+ {@const index = virtualStart + offset}
457
+ <button
458
+ type="button"
459
+ tabindex={-1}
460
+ id={optionId(index)}
461
+ role="option"
462
+ aria-selected={option.value === value}
463
+ aria-posinset={index + 1}
464
+ aria-setsize={availableOptions.length}
465
+ class="autocomplete-option"
466
+ class:active={index === activeIndex}
467
+ style={`height:${normalizedItemHeight}px;`}
468
+ onmousedown={(event: MouseEvent) => event.preventDefault()}
469
+ onclick={() => pick(option)}
470
+ >{option.label}</button>
471
+ {/each}
472
+ </div>
473
+ </div>
474
+ {:else}
475
+ {#each availableOptions as option, index (`${typeof option.value}:${option.value}`)}
476
+ <button
477
+ type="button"
478
+ tabindex={-1}
479
+ id={optionId(index)}
480
+ role="option"
481
+ aria-selected={option.value === value}
482
+ aria-posinset={index + 1}
483
+ aria-setsize={availableOptions.length}
484
+ class="autocomplete-option"
485
+ class:active={index === activeIndex}
486
+ onmousedown={(event: MouseEvent) => event.preventDefault()}
487
+ onclick={() => pick(option)}
488
+ >{option.label}</button>
489
+ {/each}
490
+ {/if}
491
+ </div>
492
+ {#if hasMore}
493
+ <button type="button" class="autocomplete-more" aria-disabled={select.isFetching}
494
+ aria-busy={select.isFetching} onclick={loadMore}>
495
+ {i18n.t('select.loadMore')}
496
+ </button>
497
+ {/if}
498
+ </div>
499
+ {/if}
500
+ </div>
501
+
502
+ <style>
503
+ .autocomplete {
504
+ position: relative;
505
+ display: flex;
506
+ flex-direction: column;
507
+ min-inline-size: 0;
508
+ }
509
+ .autocomplete > input {
510
+ width: 100%;
511
+ box-sizing: border-box;
512
+ border: 1px solid var(--border);
513
+ border-radius: var(--radius-md, 0.375rem);
514
+ background: var(--background);
515
+ color: var(--foreground);
516
+ padding: 0.5rem 2rem 0.5rem 0.75rem;
517
+ font-size: 0.875rem;
518
+ outline: none;
519
+ }
520
+ .autocomplete-clear {
521
+ position: absolute;
522
+ inset-block-start: 50%;
523
+ inset-inline-end: 0.5rem;
524
+ display: inline-flex;
525
+ transform: translateY(-50%);
526
+ border: 0;
527
+ background: transparent;
528
+ color: var(--muted-foreground);
529
+ cursor: pointer;
530
+ }
531
+ .autocomplete-clear :global(svg) {
532
+ width: 1rem;
533
+ height: 1rem;
534
+ }
535
+ .autocomplete > input:focus-visible {
536
+ border-color: var(--ring);
537
+ box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 35%, transparent);
538
+ }
539
+ .autocomplete > input:disabled {
540
+ cursor: not-allowed;
541
+ opacity: 0.5;
542
+ }
543
+ .autocomplete-popup {
544
+ position: absolute;
545
+ inset-inline: 0;
546
+ top: calc(100% + 4px);
547
+ z-index: 50;
548
+ overflow: hidden;
549
+ border: 1px solid var(--border);
550
+ border-radius: var(--radius-md, 0.375rem);
551
+ background: var(--popover);
552
+ color: var(--popover-foreground);
553
+ box-shadow: 0 8px 24px color-mix(in oklab, var(--foreground) 12%, transparent);
554
+ }
555
+ .autocomplete [role='listbox'] {
556
+ max-height: 18rem;
557
+ overflow-y: auto;
558
+ padding: 4px;
559
+ }
560
+ .autocomplete-option {
561
+ display: flex;
562
+ width: 100%;
563
+ border: 0;
564
+ background: transparent;
565
+ color: inherit;
566
+ text-align: start;
567
+ align-items: center;
568
+ border-radius: 0.125rem;
569
+ padding: 0.375rem 0.5rem;
570
+ font-size: 0.875rem;
571
+ cursor: default;
572
+ user-select: none;
573
+ }
574
+ .autocomplete-option.active,
575
+ .autocomplete-option[aria-selected='true'] {
576
+ background: var(--accent);
577
+ color: var(--accent-foreground);
578
+ }
579
+ .autocomplete-state {
580
+ padding: 0.75rem;
581
+ color: var(--muted-foreground);
582
+ font-size: 0.875rem;
583
+ }
584
+ .autocomplete-error {
585
+ display: flex;
586
+ align-items: center;
587
+ gap: 0.5rem;
588
+ padding: 0.5rem 0.75rem;
589
+ color: var(--destructive);
590
+ font-size: 0.875rem;
591
+ }
592
+ .autocomplete-error button,
593
+ .autocomplete-more {
594
+ border: 0;
595
+ background: transparent;
596
+ color: inherit;
597
+ cursor: pointer;
598
+ }
599
+ .autocomplete-error :global(svg) {
600
+ width: 1rem;
601
+ height: 1rem;
602
+ }
603
+ .autocomplete-more {
604
+ display: block;
605
+ width: 100%;
606
+ border-top: 1px solid var(--border);
607
+ padding: 0.5rem;
608
+ font-size: 0.875rem;
609
+ }
610
+ </style>
@@ -0,0 +1,25 @@
1
+ import type { Filter } from '@svadmin/core';
2
+ interface Props {
3
+ resource: string;
4
+ value?: string | number | null;
5
+ onchange?: (value: string | number | null) => void;
6
+ optionLabel?: string;
7
+ optionValue?: string;
8
+ placeholder?: string;
9
+ onSearch?: (value: string) => Filter[];
10
+ fetchSize?: number;
11
+ requestTimeout?: number;
12
+ debounce?: number;
13
+ virtualized?: boolean;
14
+ itemHeight?: number;
15
+ viewportHeight?: number;
16
+ disabled?: boolean;
17
+ id?: string;
18
+ class?: string;
19
+ 'aria-label'?: string;
20
+ 'aria-describedby'?: string;
21
+ 'aria-invalid'?: boolean | 'true' | 'false';
22
+ }
23
+ declare const AutoComplete: import("svelte").Component<Props, {}, "">;
24
+ type AutoComplete = ReturnType<typeof AutoComplete>;
25
+ export default AutoComplete;