@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
@@ -4,6 +4,7 @@
4
4
  import { useTranslation } from '@svadmin/core/i18n';
5
5
  import { captureAdminContext, captureAuthSession, useResourceContract, useSelect } from '@svadmin/core';
6
6
  import { onDestroy, untrack } from 'svelte';
7
+ import { on } from 'svelte/events';
7
8
  import type { BaseRecord, Filter } from '@svadmin/core';
8
9
  import type { HTMLButtonAttributes } from 'svelte/elements';
9
10
  import type { ButtonProps } from './ui/button/index.js';
@@ -14,12 +15,16 @@
14
15
 
15
16
  const i18n = useTranslation();
16
17
 
17
- function clickOutside(node: HTMLElement) {
18
+ function menuInteractions(node: HTMLElement) {
18
19
  const handler = (e: MouseEvent) => {
19
- if (e.target instanceof Node && !node.contains(e.target)) open = false;
20
+ if (e.target instanceof Node && !node.contains(e.target) && open) closeMenu();
20
21
  };
21
22
  document.addEventListener('click', handler, true);
22
- return { destroy() { document.removeEventListener('click', handler, true); } };
23
+ const removeKeydown = on(node, 'keydown', handleMenuKeydown);
24
+ return { destroy() {
25
+ document.removeEventListener('click', handler, true);
26
+ removeKeydown();
27
+ } };
23
28
  }
24
29
 
25
30
  interface Props extends Omit<HTMLButtonAttributes, 'value' | 'onchange' | 'children' | 'onclick' | 'type'> {
@@ -33,6 +38,10 @@
33
38
  multiple?: boolean;
34
39
  onSearch?: (value: string) => Filter[];
35
40
  fetchSize?: number;
41
+ requestTimeout?: number;
42
+ virtualized?: boolean;
43
+ itemHeight?: number;
44
+ viewportHeight?: number;
36
45
  id?: string;
37
46
  class?: string;
38
47
  }
@@ -48,6 +57,10 @@
48
57
  multiple = false,
49
58
  onSearch,
50
59
  fetchSize = 50,
60
+ requestTimeout,
61
+ virtualized = true,
62
+ itemHeight = 36,
63
+ viewportHeight = 280,
51
64
  disabled = false,
52
65
  ...restProps
53
66
  }: Props = $props();
@@ -57,23 +70,43 @@
57
70
  let searchInputValue = $state('');
58
71
  let page = $state(1);
59
72
  let appliedSearch = $state('');
73
+ let triggerButton = $state<HTMLButtonElement | HTMLAnchorElement | null>(null);
74
+ let searchInput = $state<HTMLInputElement | null>(null);
75
+ let commandRoot = $state<HTMLElement | null>(null);
76
+ let listElement = $state<HTMLElement | null>(null);
77
+ let listScrollTop = $state(0);
78
+ let activeIndex = $state(-1);
79
+ let loadMoreButton = $state<HTMLButtonElement | HTMLAnchorElement | null>(null);
60
80
  let timer: ReturnType<typeof setTimeout> | undefined;
61
81
  onDestroy(() => clearTimeout(timer));
62
82
  const normalizedFetchSize = $derived(
63
83
  Number.isSafeInteger(fetchSize) && fetchSize >= 10 && fetchSize <= 200 ? fetchSize : 50,
64
84
  );
85
+ const normalizedItemHeight = $derived(
86
+ Number.isSafeInteger(itemHeight) && itemHeight >= 32 && itemHeight <= 100 ? itemHeight : 36,
87
+ );
88
+ const normalizedViewportHeight = $derived(
89
+ Number.isSafeInteger(viewportHeight) && viewportHeight >= 120 && viewportHeight <= 800 ? viewportHeight : 280,
90
+ );
65
91
 
66
92
  const binding = useResourceContract(() => resource);
67
93
  const context = captureAdminContext();
94
+ const instanceId = $props.id();
95
+ const listboxId = $derived(`${instanceId}-listbox`);
96
+ const optionId = (index: number) => `${instanceId}-option-${index}`;
68
97
  const sourceScope = $derived({
69
98
  contract: binding.resource, provider: context.providers?.[binding.dataProviderName],
70
99
  meta: JSON.stringify(binding.meta), tenant: context.tenantCacheKey?.__svadminTenant,
71
100
  auth: context.authProvider, session: captureAuthSession(context.authProvider).cacheKey,
72
101
  access: context.accessControlProvider, router: context.routerProvider,
73
- optionLabel, optionValue, onSearch, searchable, normalizedFetchSize,
102
+ optionLabel, optionValue, onSearch, searchable, normalizedFetchSize, requestTimeout: String(requestTimeout),
74
103
  });
104
+ function sameSourceScope(left: typeof sourceScope, right: typeof sourceScope): boolean {
105
+ return (Object.keys(left) as (keyof typeof sourceScope)[]).every(key => left[key] === right[key]);
106
+ }
75
107
  type Option = { value: string | number; label: string };
76
108
  let loaded = $state.raw<{ scope: typeof sourceScope; search: string; options: Option[] }>();
109
+ let observedSourceScope = untrack(() => sourceScope);
77
110
  const defaultSearch = $derived.by(() => {
78
111
  const field = optionLabel;
79
112
  return (v: string): Filter[] => v ? [{ field, operator: 'contains', value: v }] : [];
@@ -102,6 +135,7 @@
102
135
  },
103
136
  get projectionKey() { return `combobox:${optionLabel}:${optionValue}`; },
104
137
  get fetchSize() { return normalizedFetchSize; },
138
+ get requestTimeout() { return requestTimeout; },
105
139
  get pagination() { return { current: page, pageSize: normalizedFetchSize }; },
106
140
  get defaultValue() {
107
141
  if (Array.isArray(value)) return value;
@@ -110,14 +144,21 @@
110
144
  get filters() { return searchFilters.filters; },
111
145
  get queryOptions() { return { enabled: !searchFilters.invalid }; },
112
146
  }));
147
+ onDestroy(() => select.cancel());
113
148
 
114
149
  $effect.pre(() => {
115
- void sourceScope;
150
+ const nextScope = sourceScope;
151
+ const previousScope = untrack(() => observedSourceScope);
152
+ // 字段说明和错误更新不改变查询作用域,也不应打断当前操作。
153
+ if (sameSourceScope(previousScope, nextScope)) return;
154
+ observedSourceScope = nextScope;
116
155
  clearTimeout(timer);
117
156
  searchInputValue = appliedSearch = '';
118
157
  open = false;
119
158
  page = 1;
120
159
  loaded = undefined;
160
+ listScrollTop = 0;
161
+ select.cancel();
121
162
  });
122
163
  $effect(() => {
123
164
  const data = select.query.data;
@@ -126,7 +167,7 @@
126
167
  const search = appliedSearch;
127
168
  const previous = untrack(() => loaded);
128
169
  const options = new Map<string | number, Option>(
129
- page > 1 && previous?.scope === scope && previous.search === search
170
+ page > 1 && previous && sameSourceScope(previous.scope, scope) && previous.search === search
130
171
  ? previous.options.map(option => [option.value, option]) : [],
131
172
  );
132
173
  for (const option of data.options) options.set(option.value, option);
@@ -139,22 +180,86 @@
139
180
  const availableOptions = $derived.by(() => {
140
181
  if (select.isError || searchFilters.invalid) return [];
141
182
  const options = new Map<string | number, Option>(
142
- loaded?.scope === sourceScope && loaded.search === appliedSearch
183
+ loaded && sameSourceScope(loaded.scope, sourceScope) && loaded.search === appliedSearch
143
184
  ? loaded.options.map(option => [option.value, option]) : [],
144
185
  );
145
186
  for (const option of select.options) options.set(option.value, option);
146
187
  return [...options.values()];
147
188
  });
148
189
  const totalOptions = $derived(select.query.data?.total ?? availableOptions.length);
149
- const hasMore = $derived(totalOptions > page * normalizedFetchSize && !select.isError
190
+ const shouldVirtualize = $derived(virtualized && availableOptions.length >= 200);
191
+ const virtualTotalHeight = $derived(availableOptions.length * normalizedItemHeight);
192
+ const virtualMaxScroll = $derived(Math.max(0, virtualTotalHeight - normalizedViewportHeight));
193
+ const clampedListScrollTop = $derived(Math.max(0, Math.min(listScrollTop, virtualMaxScroll)));
194
+ const virtualStart = $derived(Math.max(0, Math.floor(clampedListScrollTop / normalizedItemHeight) - 5));
195
+ const virtualEnd = $derived(Math.min(availableOptions.length,
196
+ Math.ceil((clampedListScrollTop + normalizedViewportHeight) / normalizedItemHeight) + 5));
197
+ const renderedOptions = $derived(shouldVirtualize
198
+ ? availableOptions.slice(virtualStart, virtualEnd) : availableOptions);
199
+ const activeOptionId = $derived(shouldVirtualize && activeIndex >= 0 && availableOptions[activeIndex]
200
+ && activeIndex >= virtualStart && activeIndex < virtualEnd ? optionId(activeIndex) : undefined);
201
+ const hasMore = $derived((totalOptions > page * normalizedFetchSize || (select.query.isFetching && page > 1)) && !select.isError
150
202
  && !searchFilters.invalid && searchInputValue === appliedSearch);
151
203
  const invalidOptions = $derived(select.isError || searchFilters.invalid);
204
+ // 已确认值的回显独立于候选列表的错误/搜索过滤,避免后续分页失败或搜索期间清空选中摘要。
205
+ const selectionOptions = $derived.by(() => {
206
+ const options = new Map<string | number, Option>();
207
+ // 已选记录查询失败时沿用旧实现的隐藏标签边界,不能用旧页缓存回显。
208
+ if (select.defaultValueQuery.isError) return options;
209
+ if (loaded && sameSourceScope(loaded.scope, sourceScope)) {
210
+ for (const option of loaded.options) options.set(option.value, option);
211
+ }
212
+ for (const option of select.options) options.set(option.value, option);
213
+ return options;
214
+ });
152
215
  const selectedLabels = $derived(selectedValues.flatMap(selectedValue => {
153
- const option = availableOptions.find(candidate => candidate.value === selectedValue);
216
+ const option = selectionOptions.get(selectedValue);
154
217
  return option ? [option.label] : [];
155
218
  }));
156
219
  const selectedLabel = $derived(selectedLabels.join(', '));
157
220
 
221
+ $effect.pre(() => {
222
+ if (disabled) closeMenu();
223
+ });
224
+ $effect(() => {
225
+ if (open && !disabled) (searchable ? searchInput : commandRoot)?.focus();
226
+ });
227
+ $effect.pre(() => {
228
+ if (!hasMore && open && loadMoreButton && document.activeElement === loadMoreButton) {
229
+ (searchable ? searchInput : commandRoot)?.focus();
230
+ }
231
+ });
232
+ $effect(() => {
233
+ const count = availableOptions.length;
234
+ if (activeIndex >= count) activeIndex = count > 0 ? count - 1 : -1;
235
+ });
236
+ $effect(() => {
237
+ if (!shouldVirtualize || activeIndex < 0) return;
238
+ if (activeIndex < virtualStart || activeIndex >= virtualEnd) activeIndex = -1;
239
+ });
240
+ $effect(() => {
241
+ if (listScrollTop !== clampedListScrollTop) {
242
+ listScrollTop = clampedListScrollTop;
243
+ if (listElement) listElement.scrollTop = clampedListScrollTop;
244
+ }
245
+ });
246
+
247
+ function closeMenu(restoreFocus = false): void {
248
+ open = false;
249
+ activeIndex = -1;
250
+ resetSearch();
251
+ if (restoreFocus && !disabled) triggerButton?.focus();
252
+ }
253
+
254
+ function retainMultiSelectFocus(): void {
255
+ if (!open || disabled) return;
256
+ const target = searchable ? searchInput : commandRoot;
257
+ const active = document.activeElement;
258
+ if (!target || (active !== document.body && active !== document.documentElement
259
+ && !commandRoot?.contains(active))) return;
260
+ target.focus();
261
+ }
262
+
158
263
  function handleSelect(optValue: string | number) {
159
264
  if (disabled || invalidOptions || select.isFetching || searchInputValue !== appliedSearch) return;
160
265
  if (multiple) {
@@ -162,26 +267,31 @@
162
267
  ? selectedValues.filter(selectedValue => selectedValue !== optValue)
163
268
  : [...selectedValues, optValue];
164
269
  onchange?.(next);
270
+ retainMultiSelectFocus();
165
271
  return;
166
272
  }
167
- onchange?.(optValue === value ? null : optValue);
168
- open = false;
169
- resetSearch();
273
+ const next = optValue === value ? null : optValue;
274
+ closeMenu(true);
275
+ onchange?.(next);
170
276
  }
171
277
 
172
278
  function handleClear(e: Event) {
173
279
  e.stopPropagation();
174
280
  if (disabled) return;
281
+ closeMenu(true);
175
282
  onchange?.(multiple ? [] : null);
176
- open = false;
177
- resetSearch();
178
283
  }
179
284
 
180
285
  function resetSearch(): void {
181
286
  clearTimeout(timer);
287
+ timer = undefined;
182
288
  searchInputValue = appliedSearch = '';
183
289
  page = 1;
184
290
  loaded = undefined;
291
+ listScrollTop = 0;
292
+ activeIndex = -1;
293
+ if (listElement) listElement.scrollTop = 0;
294
+ select.cancel();
185
295
  }
186
296
 
187
297
  function handleSearchInput(e: Event) {
@@ -191,10 +301,13 @@
191
301
  clearTimeout(timer);
192
302
  const scope = sourceScope;
193
303
  const apply = () => {
194
- if (scope !== sourceScope || disabled) return;
304
+ if (!open || searchInputValue !== v || !sameSourceScope(scope, sourceScope) || disabled) return;
195
305
  appliedSearch = v;
196
306
  page = 1;
197
307
  loaded = undefined;
308
+ listScrollTop = 0;
309
+ activeIndex = -1;
310
+ if (listElement) listElement.scrollTop = 0;
198
311
  };
199
312
  if (!v) apply();
200
313
  else timer = setTimeout(apply, 300);
@@ -204,19 +317,123 @@
204
317
  if (disabled || select.isFetching || !hasMore) return;
205
318
  page += 1;
206
319
  }
320
+
321
+ function handleTriggerKeydown(event: KeyboardEvent): void {
322
+ if (event.defaultPrevented || disabled || event.isComposing
323
+ || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
324
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
325
+ event.preventDefault();
326
+ if (open) (searchable ? searchInput : commandRoot)?.focus();
327
+ else open = true;
328
+ }
329
+ }
330
+
331
+ function isButtonKeydown(
332
+ event: KeyboardEvent,
333
+ ): event is KeyboardEvent & { currentTarget: HTMLButtonElement } {
334
+ return event.currentTarget instanceof HTMLButtonElement;
335
+ }
336
+
337
+ function handleTriggerKeydownWithHost(event: KeyboardEvent): void {
338
+ if (isButtonKeydown(event)) restProps.onkeydown?.(event);
339
+ handleTriggerKeydown(event);
340
+ }
341
+
342
+ function handleMenuKeydown(event: KeyboardEvent): void {
343
+ if (!open || event.key !== 'Escape' || event.defaultPrevented || event.isComposing) return;
344
+ event.preventDefault();
345
+ event.stopPropagation();
346
+ closeMenu(true);
347
+ }
348
+
349
+ function revealActive(): void {
350
+ if (!shouldVirtualize || activeIndex < 0) return;
351
+ const top = activeIndex * normalizedItemHeight;
352
+ const bottom = top + normalizedItemHeight;
353
+ if (top < clampedListScrollTop) listScrollTop = top;
354
+ else if (bottom > clampedListScrollTop + normalizedViewportHeight) {
355
+ listScrollTop = bottom - normalizedViewportHeight;
356
+ }
357
+ if (listElement) listElement.scrollTop = listScrollTop;
358
+ }
359
+
360
+ function moveActive(delta: number): void {
361
+ const count = availableOptions.length;
362
+ if (!count) {
363
+ activeIndex = -1;
364
+ return;
365
+ }
366
+ const start = activeIndex < 0 ? (delta > 0 ? -1 : count) : activeIndex;
367
+ activeIndex = (start + delta + count) % count;
368
+ revealActive();
369
+ }
370
+
371
+ // 窗口化只挂载少量 Command.Item,键盘导航必须基于完整候选而不是已挂载节点。
372
+ function handleVirtualNavigation(event: KeyboardEvent): void {
373
+ if (!open || disabled || event.defaultPrevented || event.isComposing
374
+ || event.altKey || event.ctrlKey || event.metaKey || !shouldVirtualize) return;
375
+ const target = event.target;
376
+ if (!(target instanceof Node) || (target !== searchInput
377
+ && !(commandRoot && commandRoot.contains(target)))) return;
378
+ if (!availableOptions.length) return;
379
+ const key = event.key;
380
+ if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') {
381
+ event.preventDefault();
382
+ event.stopPropagation();
383
+ if (key === 'ArrowDown' || key === 'ArrowUp') moveActive(key === 'ArrowDown' ? 1 : -1);
384
+ else {
385
+ activeIndex = key === 'Home' ? 0 : availableOptions.length - 1;
386
+ revealActive();
387
+ }
388
+ return;
389
+ }
390
+ if (key === 'Enter' && activeIndex >= 0) {
391
+ const option = availableOptions[activeIndex];
392
+ if (!option) return;
393
+ event.preventDefault();
394
+ event.stopPropagation();
395
+ handleSelect(option.value);
396
+ }
397
+ }
398
+
399
+ function handleFocusOut(event: FocusEvent): void {
400
+ const next = event.relatedTarget;
401
+ // 点击不可聚焦的选项时 relatedTarget 为空,保留弹层以完成随后的 click。
402
+ if (next instanceof Node && event.currentTarget instanceof HTMLElement
403
+ && !event.currentTarget.contains(next)) closeMenu();
404
+ }
405
+
207
406
  </script>
208
407
 
209
- <div class="svadmin-u-d89972fe17d6" use:clickOutside>
408
+ {#snippet optionRow(opt: Option, index: number)}
409
+ <Command.Item
410
+ id={optionId(index)}
411
+ disabled={select.isFetching || searchInputValue !== appliedSearch}
412
+ value={`${typeof opt.value}:${opt.value}`}
413
+ onSelect={() => handleSelect(opt.value)}
414
+ style={shouldVirtualize ? `height:${normalizedItemHeight}px;` : undefined}
415
+ class="svadmin-u-d89972fe17d6 svadmin-u-60fbb7713999 svadmin-u-50ca6ba56aa3 svadmin-u-7f6912283f11 svadmin-u-3960ffc248d9 svadmin-u-36d4469299aa svadmin-u-d5eab218aa34 svadmin-u-ec0091ee009b svadmin-u-fc7473ca09eb svadmin-u-df37b1fd9495 svadmin-u-99afb1cc3b47 svadmin-u-529780e25268"
416
+ >
417
+ <Check class="svadmin-u-d2347e8497a9 svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c {selectedValues.includes(opt.value) ? 'svadmin-u-3972e98dc84f' : 'svadmin-u-7065497e1ca0'}" />
418
+ {opt.label}
419
+ </Command.Item>
420
+ {/snippet}
421
+
422
+ <div class="svadmin-u-d89972fe17d6" use:menuInteractions onfocusout={handleFocusOut}
423
+ onkeydowncapture={handleVirtualNavigation}>
210
424
  <div class="select-control">
211
425
  <Button
426
+ bind:ref={triggerButton}
212
427
  variant="outline"
213
428
  type="button"
214
429
  class="svadmin-u-6da6a3c3f741 svadmin-u-8ef2268efbbc svadmin-u-8ecebc9f80e6"
215
- onclick={() => { open = !open; }}
430
+ onclick={() => { if (open) closeMenu(); else open = true; }}
216
431
  {...buttonRestProps}
432
+ onkeydown={handleTriggerKeydownWithHost}
217
433
  {disabled}
218
434
  aria-haspopup="listbox"
219
435
  aria-expanded={open}
436
+ aria-controls={open ? listboxId : undefined}
220
437
  aria-multiselectable={multiple || undefined}
221
438
  aria-busy={select.isFetching}
222
439
  aria-invalid={invalidOptions || restProps['aria-invalid']}
@@ -247,21 +464,32 @@
247
464
 
248
465
  {#if open && !disabled}
249
466
  <div class="svadmin-u-da4dbfbc4fdc svadmin-u-181b286668b5 svadmin-u-b6b02c0ebef6 svadmin-u-6da6a3c3f741 svadmin-u-421ac2be5045 svadmin-u-ca6bcd4b6f3f svadmin-u-e541d86d1ec8 svadmin-u-06bbb43166db">
250
- <Command.Root shouldFilter={false} class="svadmin-u-60fbb7713999 svadmin-u-8dddea0773ed svadmin-u-2cd02d11d1af svadmin-u-421ac2be5045 svadmin-u-e541d86d1ec8 svadmin-u-9b13e8ae5c9c">
467
+ <Command.Root bind:ref={commandRoot} shouldFilter={false} class="svadmin-u-60fbb7713999 svadmin-u-8dddea0773ed svadmin-u-2cd02d11d1af svadmin-u-421ac2be5045 svadmin-u-e541d86d1ec8 svadmin-u-9b13e8ae5c9c">
251
468
  {#if searchable}
252
469
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-65fdbade2025 svadmin-u-d5eab218aa34">
253
470
  <Search class="svadmin-u-82cc6c6581cd svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-012fbd121f37 svadmin-u-0b8c506a0596" />
254
471
  <Command.Input
472
+ bind:ref={searchInput}
255
473
  bind:value={searchInputValue}
256
474
  oninput={handleSearchInput}
475
+ aria-activedescendant={activeOptionId}
257
476
  placeholder={i18n.t('common.search')}
258
477
  class="svadmin-u-60fbb7713999 svadmin-u-e7a768f922d2 svadmin-u-6da6a3c3f741 svadmin-u-7f19cdf4c5bb svadmin-u-03b4dd7f172b svadmin-u-fc7473ca09eb svadmin-u-df37b1fd9495 svadmin-u-9c24ab70af61"
259
478
  />
260
479
  </div>
261
480
  {/if}
262
481
  <Command.List
482
+ bind:ref={listElement}
483
+ id={listboxId}
263
484
  role="listbox"
485
+ tabindex={-1}
486
+ aria-activedescendant={activeOptionId}
264
487
  aria-multiselectable={multiple || undefined}
488
+ style={shouldVirtualize
489
+ ? `height:${normalizedViewportHeight}px;max-height:${normalizedViewportHeight}px;overflow-y:auto;` : undefined}
490
+ onscroll={(event: Event) => {
491
+ if (event.currentTarget instanceof HTMLElement) listScrollTop = event.currentTarget.scrollTop;
492
+ }}
265
493
  class="svadmin-u-558f64349245 svadmin-u-92bf82f493b1 svadmin-u-eb6a3cef9686"
266
494
  >
267
495
  {#if select.isLoading}
@@ -274,25 +502,28 @@
274
502
  {i18n.t('select.noResults')}
275
503
  </Command.Empty>
276
504
  {/if}
277
- {#each availableOptions as opt (`${typeof opt.value}:${opt.value}`)}
278
- <Command.Item
279
- disabled={select.isFetching || searchInputValue !== appliedSearch}
280
- value={`${typeof opt.value}:${opt.value}`}
281
- onSelect={() => handleSelect(opt.value)}
282
- class="svadmin-u-d89972fe17d6 svadmin-u-60fbb7713999 svadmin-u-50ca6ba56aa3 svadmin-u-7f6912283f11 svadmin-u-3960ffc248d9 svadmin-u-36d4469299aa svadmin-u-d5eab218aa34 svadmin-u-ec0091ee009b svadmin-u-fc7473ca09eb svadmin-u-df37b1fd9495 svadmin-u-99afb1cc3b47 svadmin-u-529780e25268"
283
- >
284
- <Check class="svadmin-u-d2347e8497a9 svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c {selectedValues.includes(opt.value) ? 'svadmin-u-3972e98dc84f' : 'svadmin-u-7065497e1ca0'}" />
285
- {opt.label}
286
- </Command.Item>
287
- {/each}
505
+ {#if shouldVirtualize}
506
+ <div style={`height:${virtualTotalHeight}px;position:relative;`}>
507
+ <div style={`position:absolute;top:${virtualStart * normalizedItemHeight}px;left:0;right:0;`}>
508
+ {#each renderedOptions as opt, offset (`${typeof opt.value}:${opt.value}`)}
509
+ {@render optionRow(opt, virtualStart + offset)}
510
+ {/each}
511
+ </div>
512
+ </div>
513
+ {:else}
514
+ {#each renderedOptions as opt, index (`${typeof opt.value}:${opt.value}`)}
515
+ {@render optionRow(opt, index)}
516
+ {/each}
517
+ {/if}
288
518
  {/if}
289
519
  </Command.List>
290
- {#if hasMore}
291
- <Button type="button" variant="ghost" size="sm" disabled={select.isFetching} onclick={loadMore}>
292
- {i18n.t('select.loadMore')}
293
- </Button>
294
- {/if}
295
520
  </Command.Root>
521
+ {#if hasMore}
522
+ <Button bind:ref={loadMoreButton} type="button" variant="ghost" size="sm"
523
+ aria-disabled={select.isFetching} aria-busy={select.isFetching} onclick={loadMore}>
524
+ {i18n.t('select.loadMore')}
525
+ </Button>
526
+ {/if}
296
527
  </div>
297
528
  {/if}
298
529
  </div>
@@ -302,6 +533,11 @@
302
533
  display: flex;
303
534
  align-items: center;
304
535
  gap: 4px;
536
+ min-inline-size: 0;
537
+ }
538
+ .select-control > :global(button[data-slot='button']:first-child) {
539
+ flex: 1 1 auto;
540
+ min-inline-size: 0;
305
541
  }
306
542
  .select-error {
307
543
  color: var(--destructive);
@@ -11,6 +11,10 @@ interface Props extends Omit<HTMLButtonAttributes, 'value' | 'onchange' | 'child
11
11
  multiple?: boolean;
12
12
  onSearch?: (value: string) => Filter[];
13
13
  fetchSize?: number;
14
+ requestTimeout?: number;
15
+ virtualized?: boolean;
16
+ itemHeight?: number;
17
+ viewportHeight?: number;
14
18
  id?: string;
15
19
  class?: string;
16
20
  }
@@ -103,7 +103,7 @@
103
103
  {#if adminContext.accessControlProvider}
104
104
  <CanAccess resource={r.name} action="list">
105
105
  <Command.Item
106
- value={r.name}
106
+ value={`${r.name} ${r.label}`}
107
107
  onSelect={() => act(() => navigation.list(r.name))}
108
108
  class={itemClass}
109
109
  >
@@ -113,7 +113,7 @@
113
113
  </CanAccess>
114
114
  {:else}
115
115
  <Command.Item
116
- value={r.name}
116
+ value={`${r.name} ${r.label}`}
117
117
  onSelect={() => act(() => navigation.list(r.name))}
118
118
  class={itemClass}
119
119
  >
@@ -132,13 +132,13 @@
132
132
  {#if r.canCreate !== false}
133
133
  {#if adminContext.accessControlProvider}
134
134
  <CanAccess resource={r.name} action="create">
135
- <Command.Item value={"create-" + r.name} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
135
+ <Command.Item value={`${"create-" + r.name} ${r.label}`} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
136
136
  <Plus class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-bfa603190748" />
137
137
  {i18n.t('common.create')} {r.label}
138
138
  </Command.Item>
139
139
  </CanAccess>
140
140
  {:else}
141
- <Command.Item value={"create-" + r.name} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
141
+ <Command.Item value={`${"create-" + r.name} ${r.label}`} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
142
142
  <Plus class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-bfa603190748" />
143
143
  {i18n.t('common.create')} {r.label}
144
144
  </Command.Item>
@@ -4,7 +4,10 @@
4
4
  DashboardContractError, captureAdminContext, captureAuthSession, createEnterpriseRequestContext,
5
5
  decodeDashboardQuery, decodeDashboardSnapshot,
6
6
  } from '@svadmin/core';
7
- import type { DashboardProvider, DashboardSnapshot, EnterpriseProviderRequestContext } from '@svadmin/core';
7
+ import type {
8
+ DashboardDrilldown, DashboardMetric, DashboardProvider, DashboardQuery, DashboardSnapshot, DashboardTimeRange,
9
+ DashboardWidget, EnterpriseProviderRequestContext,
10
+ } from '@svadmin/core';
8
11
  import { useTranslation } from '@svadmin/core/i18n';
9
12
  import { onDestroy, untrack } from 'svelte';
10
13
  import MetricStrip, { type MetricStripItem } from './MetricStrip.svelte';
@@ -15,15 +18,20 @@
15
18
  dashboardId: string;
16
19
  requestContext?: EnterpriseProviderRequestContext;
17
20
  filters?: readonly { field: string; value: string | number | boolean | null }[];
21
+ timeRange?: DashboardTimeRange;
22
+ onDrillDown?: ((input: DashboardDrilldown) => void | Promise<void>) | undefined;
18
23
  title?: string;
19
24
  }
20
- let { provider, dashboardId, requestContext, filters = [], title }: Props = $props();
25
+ let { provider, dashboardId, requestContext, filters = [], timeRange, onDrillDown, title }: Props = $props();
21
26
  const context = captureAdminContext();
22
27
  const i18n = useTranslation();
23
28
  let snapshot = $state<DashboardSnapshot>();
29
+ let loadedQuery: DashboardQuery | undefined;
24
30
  let loading = $state(false);
25
31
  let error = $state<'load' | 'invalid'>();
32
+ let drillError = $state(false);
26
33
  let epoch = 0;
34
+ let drillSequence = 0;
27
35
  let mounted = true;
28
36
  let generatedContext: EnterpriseProviderRequestContext | undefined;
29
37
  onDestroy(() => { mounted = false; });
@@ -45,13 +53,18 @@
45
53
  const request = decodeDashboardQuery({
46
54
  dashboardId: dashboardId.trim(),
47
55
  ...(filters.length ? { filters: filters.map(filter => ({ ...filter })) } : {}),
56
+ ...(timeRange ? { timeRange: { from: timeRange.from, to: timeRange.to } } : {}),
48
57
  });
58
+ // 独立保存已校验查询;Provider 可能原位修改传入的 request。
59
+ const loaded = decodeDashboardQuery(request);
49
60
  const result = decodeDashboardSnapshot(await provider.get(resolveContext(), request));
50
61
  if (!mounted || currentEpoch !== epoch) return;
51
62
  snapshot = result;
63
+ loadedQuery = loaded;
52
64
  } catch (cause) {
53
65
  if (!mounted || currentEpoch !== epoch) return;
54
66
  snapshot = undefined;
67
+ loadedQuery = undefined;
55
68
  error = cause instanceof DashboardContractError ? 'invalid' : 'load';
56
69
  } finally {
57
70
  if (mounted && currentEpoch === epoch) loading = false;
@@ -63,14 +76,58 @@
63
76
  id: `${widget.id}:${metric.id}`, label: metric.label,
64
77
  value: typeof metric.value === 'boolean' ? String(metric.value) : metric.value ?? '—',
65
78
  ...(metric.tone ? { tone: metric.tone } : {}),
66
- ...(metric.href ? { href: metric.href } : {}),
79
+ ...(onDrillDown
80
+ ? { onActivate: () => drillDown(widget, metric) }
81
+ : metric.href ? { href: metric.href } : {}),
67
82
  })) : [],
68
83
  ) ?? []);
69
84
 
85
+ function drillDown(widget: DashboardWidget, metric?: DashboardMetric): void {
86
+ const query = loadedQuery;
87
+ if (!onDrillDown || !query) return;
88
+ const currentLoad = epoch;
89
+ const currentDrill = ++drillSequence;
90
+ drillError = false;
91
+ const settle = (failed: boolean): void => {
92
+ if (!mounted || currentLoad !== epoch || currentDrill !== drillSequence) return;
93
+ drillError = failed;
94
+ };
95
+ const input: DashboardDrilldown = {
96
+ dashboardId: query.dashboardId,
97
+ widget: { id: widget.id, title: widget.title, kind: widget.kind },
98
+ ...(metric ? { metric: { ...metric } } : {}),
99
+ filters: (query.filters ?? []).map(filter => ({ ...filter })),
100
+ ...(query.timeRange ? { timeRange: { ...query.timeRange } } : {}),
101
+ };
102
+ try {
103
+ const result = onDrillDown(input);
104
+ void Promise.resolve(result).then(() => settle(false), () => settle(true));
105
+ } catch { settle(true); }
106
+ }
107
+
108
+ function trackQueryInputs(): void {
109
+ for (const filter of filters) {
110
+ void filter.field;
111
+ void filter.value;
112
+ }
113
+ if (timeRange) {
114
+ void timeRange.from;
115
+ void timeRange.to;
116
+ }
117
+ }
118
+
119
+ // 显式跟踪嵌套查询值;Provider 执行留在下方 untrack 内,避免 effect 重入。
70
120
  $effect(() => {
71
- void provider; void dashboardId; void requestContext; void filters;
121
+ void provider; void dashboardId; void requestContext;
122
+ trackQueryInputs();
72
123
  void captureAuthSession(context.authProvider).cacheKey;
73
- untrack(() => { snapshot = undefined; void load(); });
124
+ untrack(() => {
125
+ drillSequence += 1;
126
+ drillError = false;
127
+ snapshot = undefined;
128
+ loadedQuery = undefined;
129
+ void load();
130
+ });
74
131
  });
75
132
  </script>
76
133
 
@@ -82,10 +139,19 @@
82
139
  </Button>
83
140
  </header>
84
141
  {#if loading}<div role="status"><Loader2 aria-hidden="true" /> {i18n.t('common.loading')}</div>
85
- {:else if error}<div role="alert">{i18n.t(`dashboard.${error}`)}</div>
142
+ {:else if error}<div role="alert">{i18n.t(`dashboard.${error}`)}
143
+ <Button type="button" variant="outline" size="sm" onclick={() => void load()}>{i18n.t('common.retry')}</Button>
144
+ </div>
86
145
  {:else if snapshot}
146
+ {#if drillError}<div role="alert">{i18n.t('common.operationFailed')}</div>{/if}
87
147
  {#each snapshot.widgets.filter(widget => widget.kind === 'text') as widget (widget.id)}
88
- <section aria-labelledby={`dashboard-widget-${widget.id}`}><h3 id={`dashboard-widget-${widget.id}`}>{widget.title}</h3><p>{widget.text}</p></section>
148
+ <section aria-labelledby={`dashboard-widget-${widget.id}`}><h3 id={`dashboard-widget-${widget.id}`}>{widget.title}</h3><p>{widget.text}</p>
149
+ {#if onDrillDown}
150
+ <Button type="button" variant="outline" size="sm"
151
+ aria-label={`${i18n.t('dashboard.drilldown')}: ${widget.title}`}
152
+ onclick={() => drillDown(widget)}>{i18n.t('dashboard.drilldown')}</Button>
153
+ {/if}
154
+ </section>
89
155
  {/each}
90
156
  <MetricStrip items={metricItems} ariaLabel={title ?? i18n.t('dashboard.metrics')} />
91
157
  {:else}<div role="status">{i18n.t('dashboard.empty')}</div>{/if}