@svadmin/ui 0.88.1 → 0.92.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 (172) 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 +265 -18
  14. package/dist/components/AutoForm.svelte.d.ts +19 -2
  15. package/dist/components/AutoTable.svelte +141 -36
  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 +62 -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/Toast.svelte +13 -2
  109. package/dist/components/Transfer.svelte +67 -5
  110. package/dist/components/Transfer.svelte.d.ts +4 -0
  111. package/dist/components/TreeSelect.svelte +337 -77
  112. package/dist/components/TreeSelect.svelte.d.ts +4 -0
  113. package/dist/components/TreeTable.svelte +6 -1
  114. package/dist/components/TwoFactorAuthPage.svelte +8 -4
  115. package/dist/components/UpdatePasswordPage.svelte +1 -1
  116. package/dist/components/VersionDiffViewer.svelte +9 -7
  117. package/dist/components/account/CompanyProfilePage.svelte +13 -12
  118. package/dist/components/account/MemberDirectory.svelte +14 -10
  119. package/dist/components/account/SettingsEnterprisePage.svelte +18 -18
  120. package/dist/components/content/AuditTimeline.svelte +8 -6
  121. package/dist/components/content/DataState.svelte +1 -1
  122. package/dist/components/content/FileList.svelte +44 -13
  123. package/dist/components/content/FileList.svelte.d.ts +4 -2
  124. package/dist/components/content/FileListRow.svelte +87 -0
  125. package/dist/components/content/FileListRow.svelte.d.ts +11 -0
  126. package/dist/components/content/ImportDropzone.svelte +109 -12
  127. package/dist/components/content/ImportDropzone.svelte.d.ts +1 -0
  128. package/dist/components/content/IntegrationCard.svelte +3 -3
  129. package/dist/components/content/IntegrationCard.svelte.d.ts +2 -0
  130. package/dist/components/content/MediaThumbnail.svelte +6 -2
  131. package/dist/components/detail-workspace.d.ts +60 -0
  132. package/dist/components/detail-workspace.js +3 -0
  133. package/dist/components/enterprise/json-schema-form.d.ts +1 -1
  134. package/dist/components/enterprise/recursive-schema-form.d.ts +15 -1
  135. package/dist/components/enterprise/recursive-schema-form.js +288 -14
  136. package/dist/components/enterprise/schema-form-references.d.ts +14 -0
  137. package/dist/components/enterprise/schema-form-references.js +316 -0
  138. package/dist/components/fields/ArrayField.svelte +178 -14
  139. package/dist/components/fields/ArrayField.svelte.d.ts +17 -3
  140. package/dist/components/fields/array-field-metadata.d.ts +4 -0
  141. package/dist/components/fields/array-field-metadata.js +36 -0
  142. package/dist/components/file-upload-reference.d.ts +11 -0
  143. package/dist/components/file-upload-reference.js +50 -0
  144. package/dist/components/file-upload-types.d.ts +32 -0
  145. package/dist/components/file-upload-types.js +1 -0
  146. package/dist/components/form-contract.upload-type-fixture.svelte +108 -0
  147. package/dist/components/form-contract.upload-type-fixture.svelte.d.ts +18 -0
  148. package/dist/components/json-schema-form-state.d.ts +19 -0
  149. package/dist/components/list-deletion.svelte.js +6 -1
  150. package/dist/components/list-state.svelte.d.ts +12 -1
  151. package/dist/components/list-state.svelte.js +56 -16
  152. package/dist/components/numeric-format.d.ts +44 -0
  153. package/dist/components/numeric-format.js +198 -0
  154. package/dist/components/record-detail.svelte.js +2 -0
  155. package/dist/components/saved-list-views.d.ts +9 -2
  156. package/dist/components/saved-list-views.js +42 -11
  157. package/dist/components/saved-list-views.svelte.d.ts +3 -0
  158. package/dist/components/saved-list-views.svelte.js +56 -7
  159. package/dist/components/task-action.svelte.d.ts +1 -0
  160. package/dist/components/task-action.svelte.js +55 -3
  161. package/dist/components/task-utils.d.ts +1 -0
  162. package/dist/components/task-utils.js +18 -0
  163. package/dist/components/ui/input/input.svelte +1 -1
  164. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  165. package/dist/components/ui/sheet/sheet.svelte +4 -1
  166. package/dist/components/version-history.d.ts +14 -0
  167. package/dist/components/version-history.js +32 -0
  168. package/dist/components/workspace-tab-state.d.ts +78 -0
  169. package/dist/components/workspace-tab-state.js +587 -0
  170. package/dist/index.d.ts +13 -1
  171. package/dist/index.js +4 -0
  172. package/package.json +5 -5
@@ -1,6 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { ChevronRight, Check, X, ChevronsUpDown } from '@lucide/svelte';
3
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
4
+ import { onDestroy, tick, untrack } from 'svelte';
3
5
  import { cn } from '../utils.js';
6
+ import { Button } from './ui/button/index.js';
4
7
  import * as Popover from './ui/popover/index.js';
5
8
  import { useTranslation } from '@svadmin/core/i18n';
6
9
 
@@ -8,6 +11,7 @@
8
11
  value: string | number;
9
12
  label: string;
10
13
  children?: CascaderOption[];
14
+ hasChildren?: boolean;
11
15
  disabled?: boolean;
12
16
  isLeaf?: boolean;
13
17
  }
@@ -15,6 +19,10 @@
15
19
  const i18n = useTranslation();
16
20
 
17
21
  interface Props {
22
+ id?: string;
23
+ ariaLabel?: string;
24
+ describedby?: string;
25
+ invalid?: boolean;
18
26
  options?: CascaderOption[];
19
27
  value?: (string | number)[];
20
28
  separator?: string;
@@ -22,25 +30,213 @@
22
30
  changeOnSelect?: boolean;
23
31
  disabled?: boolean;
24
32
  allowClear?: boolean;
33
+ loadChildren?: (node: CascaderOption) => Promise<CascaderOption[]>;
25
34
  class?: string;
26
35
  onchange?: (value: (string | number)[] | undefined, selectedOptions: CascaderOption[]) => void;
27
36
  }
28
37
 
29
38
  let {
30
- options = [],
39
+ id,
40
+ ariaLabel,
41
+ describedby,
42
+ invalid = false,
43
+ options = $bindable([]),
31
44
  value = $bindable(undefined),
32
45
  separator = ' / ',
33
46
  placeholder,
34
47
  changeOnSelect = false,
35
48
  disabled = false,
36
49
  allowClear = true,
50
+ loadChildren,
37
51
  class: className,
38
52
  onchange,
39
53
  }: Props = $props();
40
54
 
55
+ const instanceId = $props.id();
56
+ const triggerId = $derived(id ?? `${instanceId}-trigger`);
57
+ const panelId = $derived(`${triggerId}-panel`);
41
58
  let open = $state(false);
59
+ let triggerElement = $state<HTMLButtonElement>();
60
+ let panelElement = $state<HTMLDivElement>();
42
61
  // Active selected path while navigating panels
43
62
  let activePath = $state<(string | number)[]>([]);
63
+ interface ChildLoad {
64
+ node: CascaderOption;
65
+ path: (string | number)[];
66
+ loader: Props['loadChildren'];
67
+ treeEpoch: number;
68
+ promise: Promise<boolean>;
69
+ }
70
+ const loads = new SvelteMap<string, ChildLoad>();
71
+ let loadingValues = new SvelteSet<string>();
72
+ let loadErrors = new SvelteMap<string, ChildLoad>();
73
+ let mounted = true;
74
+ let treeEpoch = 0;
75
+ // 只缓存实际加载结果,按完整路径隔离;不改写宿主 options,也不附加任何标记。
76
+ interface LoadedChildren {
77
+ node: CascaderOption;
78
+ children: CascaderOption[];
79
+ }
80
+ const loadedChildren = new SvelteMap<string, LoadedChildren>();
81
+ let observedOptions = untrack(() => options);
82
+ let observedLoader = untrack(() => loadChildren);
83
+ // 用户焦点意图版本:延迟恢复只能在仍属于原操作时执行。
84
+ let focusSeq = 0;
85
+ let restoringFocus = false;
86
+ // 加载态替换当前聚焦的 Retry 会移除焦点锚点,浏览器随后的布局内 focusin
87
+ // 不是用户切换分支;仅忽略该次焦点迁移,仍由原重试操作恢复焦点。
88
+ let displacedFocusElement: HTMLElement | null = null;
89
+ onDestroy(() => { mounted = false; loads.clear(); });
90
+
91
+ // hasChildren 只表达“是否应展开子列”,不保证 children 已存在(异步懒加载)。
92
+ function hasChildren(option: CascaderOption | undefined): boolean {
93
+ if (!option || option.isLeaf) return false;
94
+ if (option.hasChildren !== undefined) return option.hasChildren;
95
+ return Boolean(option.children && option.children.length > 0);
96
+ }
97
+
98
+ // 路径身份:不同父级下允许出现相同 value,加载状态必须按完整路径隔离。
99
+ function pathId(path: (string | number)[]): string {
100
+ return JSON.stringify(path);
101
+ }
102
+
103
+ function isNodeUnavailable(node: CascaderOption, path: (string | number)[]): boolean {
104
+ if (disabled || node.disabled === true) return true;
105
+ const ancestors = getOptionsForPath(path.slice(0, -1));
106
+ return ancestors.length !== path.length - 1 || ancestors.some(ancestor => ancestor.disabled);
107
+ }
108
+
109
+ // 在宿主实时树上叠加实际加载结果;宿主补齐/替换 children、改为 isLeaf
110
+ // 或替换节点对象后,旧缓存立即失效。不同路径的条目各自独立,不会互相覆盖。
111
+ function mergeLoadedChildren(nodes: CascaderOption[], prefix: (string | number)[]): CascaderOption[] {
112
+ let changed = false;
113
+ const next = nodes.map((node) => {
114
+ const path = [...prefix, node.value];
115
+ const entry = loadedChildren.get(pathId(path));
116
+ const applyLoaded = node.children === undefined && node.isLeaf !== true
117
+ && !!entry && entry.node === node;
118
+ const children = applyLoaded ? entry.children : node.children;
119
+ const mergedChildren = children ? mergeLoadedChildren(children, path) : undefined;
120
+ if (children === node.children && mergedChildren === children) return node;
121
+ changed = true;
122
+ const { children: _nodeChildren, ...nodeWithoutChildren } = node;
123
+ return {
124
+ ...nodeWithoutChildren,
125
+ ...(mergedChildren !== undefined ? { children: mergedChildren } : {}),
126
+ ...(applyLoaded && entry ? { hasChildren: entry.children.length > 0 } : {}),
127
+ };
128
+ });
129
+ return changed ? next : nodes;
130
+ }
131
+
132
+ // 每次读取都从宿主当前对象重建,避免克隆祖先形成旧快照。
133
+ const effectiveOptions = $derived(mergeLoadedChildren(options, []));
134
+
135
+ // 外部替换选项或加载器、禁用字段时,旧请求不得再写入或提交。
136
+ $effect.pre(() => {
137
+ if (options !== observedOptions) {
138
+ // 宿主替换 options 一定推进树版本;内部子级缓存随之失效。
139
+ observedOptions = options;
140
+ loadedChildren.clear();
141
+ treeEpoch += 1;
142
+ }
143
+ const loader = loadChildren;
144
+ const unavailable = disabled;
145
+ if (loader !== observedLoader || unavailable) {
146
+ observedLoader = loader;
147
+ }
148
+ for (const [key, request] of loads) {
149
+ const resolvedPath = getOptionsForPath(request.path);
150
+ const current = resolvedPath.at(-1);
151
+ if (unavailable || request.treeEpoch !== treeEpoch || loader !== request.loader
152
+ || resolvedPath.length !== request.path.length
153
+ || !current || current !== request.node || isNodeUnavailable(current, request.path)) {
154
+ loads.delete(key);
155
+ loadingValues.delete(key);
156
+ loadErrors.delete(key);
157
+ }
158
+ }
159
+ for (const [key, request] of loadErrors) {
160
+ const resolvedPath = getOptionsForPath(request.path);
161
+ const current = resolvedPath.at(-1);
162
+ if (unavailable || request.treeEpoch !== treeEpoch || loader !== request.loader
163
+ || resolvedPath.length !== request.path.length
164
+ || !current || current !== request.node || isNodeUnavailable(current, request.path)
165
+ || !hasChildren(current) || current.children) loadErrors.delete(key);
166
+ }
167
+ });
168
+
169
+ async function ensureChildren(node: CascaderOption, path: (string | number)[]): Promise<boolean> {
170
+ const key = pathId(path);
171
+ if (!mounted || isNodeUnavailable(node, path)) return false;
172
+ if (node.children) return true;
173
+ if (!hasChildren(node)) return true;
174
+ const existing = loads.get(key);
175
+ if (existing) return existing.promise;
176
+ const loader = loadChildren;
177
+ if (!loader) {
178
+ loadErrors.set(key, { node, path, loader, treeEpoch, promise: Promise.resolve(false) });
179
+ return false;
180
+ }
181
+ const request: ChildLoad = { node, path, loader, treeEpoch, promise: Promise.resolve(false) };
182
+ loads.set(key, request);
183
+ const current = () => {
184
+ if (!mounted || disabled || loads.get(key) !== request
185
+ || treeEpoch !== request.treeEpoch || loadChildren !== request.loader) return false;
186
+ const resolvedPath = getOptionsForPath(path);
187
+ const currentNode = resolvedPath.at(-1);
188
+ // 请求期间路径节点被替换、变成叶子或由宿主补齐 children,都属于新所有权。
189
+ return resolvedPath.length === path.length
190
+ && !!currentNode
191
+ && currentNode === request.node
192
+ && currentNode.children === undefined
193
+ && currentNode.isLeaf !== true
194
+ && !isNodeUnavailable(currentNode, path);
195
+ };
196
+ loadingValues.add(key);
197
+ loadErrors.delete(key);
198
+ request.promise = (async () => {
199
+ try {
200
+ const children = await loader(request.node);
201
+ if (!current()) return false;
202
+ loadedChildren.set(key, { node: request.node, children });
203
+ return true;
204
+ } catch {
205
+ if (current()) loadErrors.set(key, request);
206
+ return false;
207
+ } finally {
208
+ if (loads.get(key) === request) {
209
+ loads.delete(key);
210
+ loadingValues.delete(key);
211
+ }
212
+ }
213
+ })();
214
+ return request.promise;
215
+ }
216
+
217
+ function samePath(left: (string | number)[], right: (string | number)[]): boolean {
218
+ return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
219
+ }
220
+
221
+ async function activateOption(colIndex: number, option: CascaderOption, commitSelection: boolean): Promise<{ loaded: boolean; path: (string | number)[] }> {
222
+ if (isOptionDisabled(colIndex, option)) return { loaded: false, path: activePath };
223
+ const nextPath = [...activePath.slice(0, colIndex), option.value];
224
+ activePath = nextPath;
225
+ const loaded = await ensureChildren(option, nextPath);
226
+ if (!loaded || !open || !samePath(activePath, nextPath)) return { loaded: false, path: nextPath };
227
+ const currentOption = getOptionsForPath(nextPath).at(-1);
228
+ if (!currentOption || isOptionDisabled(colIndex, currentOption)) return { loaded: false, path: nextPath };
229
+ const currentHasChildren = hasChildren(currentOption);
230
+ if (commitSelection && (!currentHasChildren || changeOnSelect)) {
231
+ // 同一异步分支在请求期间被重复激活时,只有第一次可以提交。
232
+ if (!value || !samePath(value, nextPath)) {
233
+ value = nextPath;
234
+ onchange?.(nextPath, getOptionsForPath(nextPath));
235
+ }
236
+ }
237
+ if (commitSelection && !currentHasChildren) open = false;
238
+ return { loaded: true, path: nextPath };
239
+ }
44
240
 
45
241
  $effect(() => {
46
242
  if (value && Array.isArray(value)) {
@@ -52,17 +248,15 @@
52
248
 
53
249
  // Calculate columns based on activePath
54
250
  const columns = $derived.by<CascaderOption[][]>(() => {
55
- const cols: CascaderOption[][] = [options];
56
- let currentOptions = options;
251
+ const cols: CascaderOption[][] = [effectiveOptions];
252
+ let currentOptions = effectiveOptions;
57
253
 
58
254
  for (const val of activePath) {
59
255
  const selectedOpt = currentOptions.find((o) => o.value === val);
60
- if (selectedOpt && selectedOpt.children && selectedOpt.children.length > 0) {
61
- cols.push(selectedOpt.children);
62
- currentOptions = selectedOpt.children;
63
- } else {
64
- break;
65
- }
256
+ if (!selectedOpt || !hasChildren(selectedOpt)) break;
257
+ cols.push(selectedOpt.children ?? []);
258
+ if (!selectedOpt.children) break;
259
+ currentOptions = selectedOpt.children;
66
260
  }
67
261
 
68
262
  return cols;
@@ -72,13 +266,14 @@
72
266
  const selectedOptions = $derived.by<CascaderOption[]>(() => {
73
267
  if (!value || !Array.isArray(value) || value.length === 0) return [];
74
268
  const opts: CascaderOption[] = [];
75
- let currentLevel = options;
269
+ let currentLevel = effectiveOptions;
76
270
 
77
271
  for (const val of value) {
78
272
  const found = currentLevel.find((o) => o.value === val);
79
273
  if (found) {
80
274
  opts.push(found);
81
- currentLevel = found.children ?? [];
275
+ if (!hasChildren(found) || !found.children) break;
276
+ currentLevel = found.children;
82
277
  } else {
83
278
  break;
84
279
  }
@@ -91,52 +286,177 @@
91
286
  return selectedOptions.map((o) => o.label).join(separator);
92
287
  });
93
288
 
94
- function handleSelectOption(colIndex: number, option: CascaderOption) {
95
- if (option.disabled) return;
96
-
97
- const nextPath = [...activePath.slice(0, colIndex), option.value];
98
- activePath = nextPath;
99
-
100
- const hasChildren = Boolean(option.children && option.children.length > 0 && !option.isLeaf);
101
-
102
- if (!hasChildren || changeOnSelect) {
103
- value = nextPath;
104
- const currentSelected = getOptionsForPath(nextPath);
105
- onchange?.(nextPath, currentSelected);
106
- if (!hasChildren) {
107
- open = false;
108
- }
109
- }
289
+ async function handleSelectOption(colIndex: number, option: CascaderOption): Promise<void> {
290
+ focusSeq += 1;
291
+ await activateOption(colIndex, option, true);
110
292
  }
111
293
 
112
294
  function getOptionsForPath(path: (string | number)[]): CascaderOption[] {
113
295
  const result: CascaderOption[] = [];
114
- let currentLevel = options;
296
+ let currentLevel = effectiveOptions;
115
297
  for (const val of path) {
116
298
  const found = currentLevel.find((o) => o.value === val);
117
299
  if (found) {
118
300
  result.push(found);
119
- currentLevel = found.children ?? [];
120
- }
301
+ if (!hasChildren(found) || !found.children) break;
302
+ currentLevel = found.children;
303
+ } else break;
121
304
  }
122
305
  return result;
123
306
  }
124
307
 
125
- function clearAll(event?: MouseEvent) {
308
+ function isOptionDisabled(colIndex: number, option: CascaderOption): boolean {
309
+ const ancestors = getOptionsForPath(activePath.slice(0, colIndex));
310
+ return disabled || option.disabled === true || ancestors.length !== colIndex
311
+ || ancestors.some(ancestor => ancestor.disabled);
312
+ }
313
+
314
+ function columnParent(colIndex: number): CascaderOption | undefined {
315
+ if (colIndex === 0) return undefined;
316
+ return getOptionsForPath(activePath.slice(0, colIndex)).at(-1);
317
+ }
318
+
319
+ async function retryColumn(colIndex: number, event: MouseEvent): Promise<void> {
320
+ event.stopPropagation();
321
+ const retryElement = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
322
+ focusSeq += 1;
323
+ const intent = focusSeq;
324
+ const parentPath = activePath.slice(0, colIndex);
325
+ const parent = getOptionsForPath(parentPath).at(-1);
326
+ if (!parent || disabled) return;
327
+ displacedFocusElement = retryElement && document.activeElement === retryElement ? retryElement : null;
328
+ const loaded = await ensureChildren(parent, parentPath);
329
+ if (displacedFocusElement === retryElement) displacedFocusElement = null;
330
+ if (!loaded || !open || intent !== focusSeq) return;
331
+ await tick();
332
+ if (intent !== focusSeq || !open || !samePath(activePath.slice(0, colIndex), parentPath)) return;
333
+ focusColumnButton(colIndex);
334
+ }
335
+
336
+ function clearAll(event: MouseEvent) {
126
337
  event?.stopPropagation();
338
+ if (disabled) return;
339
+ open = false;
340
+ triggerElement?.focus();
127
341
  value = undefined;
128
342
  activePath = [];
129
343
  onchange?.(undefined, []);
130
344
  }
345
+
346
+ function focusColumnButton(columnIndex: number, optionIndex?: number): void {
347
+ if (!open || disabled) return;
348
+ const column = panelElement?.querySelector<HTMLElement>(`[data-cascader-column="${columnIndex}"]`);
349
+ if (!column) return;
350
+ const buttons = [...column.querySelectorAll<HTMLButtonElement>('button')];
351
+ const start = optionIndex ?? 0;
352
+ const indexes = optionIndex === undefined
353
+ ? buttons.map((_, index) => index)
354
+ : [start, ...buttons.map((_, index) => index).filter(index => index !== start)];
355
+ const target = indexes.map(index => buttons[index]).find(button => button && !button.disabled);
356
+ if (!target) return;
357
+ restoringFocus = true;
358
+ try { target.focus(); } finally { restoringFocus = false; }
359
+ }
360
+
361
+ function handlePanelFocusIn(): void {
362
+ // 用户把焦点移到其他分支或同级按钮后,旧的延迟恢复不再拥有焦点。
363
+ if (restoringFocus) return;
364
+ if (displacedFocusElement && !displacedFocusElement.isConnected) {
365
+ displacedFocusElement = null;
366
+ return;
367
+ }
368
+ displacedFocusElement = null;
369
+ focusSeq += 1;
370
+ }
371
+
372
+ async function handleOpenAutoFocus(event: Event): Promise<void> {
373
+ // 避免上游延迟自动聚焦覆盖用户已经完成的列间导航。
374
+ event.preventDefault();
375
+ await tick();
376
+ if (!open || !panelElement || panelElement.contains(document.activeElement)) return;
377
+ focusColumnButton(0);
378
+ if (!panelElement.contains(document.activeElement)) panelElement.focus();
379
+ }
380
+
381
+ async function handleCloseAutoFocus(event: Event): Promise<void> {
382
+ // 重挂载会重置上游焦点记忆;待状态刷新后仅在真实关闭时回焦。
383
+ event.preventDefault();
384
+ await tick();
385
+ if (!open) triggerElement?.focus();
386
+ }
387
+
388
+ async function handleOptionKeydown(event: KeyboardEvent, colIndex: number, option: CascaderOption): Promise<void> {
389
+ const column = event.currentTarget as HTMLButtonElement;
390
+ if (!open || isOptionDisabled(colIndex, option) || column.disabled
391
+ || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
392
+ focusSeq += 1;
393
+ const intent = focusSeq;
394
+ const buttons = [...(column.parentElement?.querySelectorAll<HTMLButtonElement>('button') ?? [])];
395
+ const currentIndex = buttons.indexOf(column);
396
+ if (currentIndex < 0) return;
397
+
398
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Home' || event.key === 'End') {
399
+ event.preventDefault();
400
+ const direction = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0;
401
+ const enabled = buttons.map((button, index) => ({ button, index })).filter(({ button }) => !button.disabled);
402
+ if (enabled.length === 0) return;
403
+ const currentEnabledIndex = enabled.findIndex(({ index }) => index === currentIndex);
404
+ const next = event.key === 'Home'
405
+ ? enabled[0]
406
+ : event.key === 'End'
407
+ ? enabled.at(-1)
408
+ : enabled[(currentEnabledIndex + direction + enabled.length) % enabled.length];
409
+ next?.button.focus();
410
+ return;
411
+ }
412
+
413
+ if (event.key === 'ArrowRight') {
414
+ if (!hasChildren(option)) return;
415
+ event.preventDefault();
416
+ const result = await activateOption(colIndex, option, false);
417
+ if (intent !== focusSeq || !open || !samePath(activePath, result.path)) return;
418
+ await tick();
419
+ if (intent !== focusSeq || !open || !samePath(activePath, result.path)) return;
420
+ focusColumnButton(colIndex + 1);
421
+ return;
422
+ }
423
+
424
+ if (event.key === 'ArrowLeft') {
425
+ event.preventDefault();
426
+ if (colIndex === 0) {
427
+ open = false;
428
+ await tick();
429
+ triggerElement?.focus();
430
+ return;
431
+ }
432
+ const parentPath = activePath.slice(0, colIndex - 1);
433
+ const parentOptions = getOptionsForPath(parentPath).at(-1)?.children ?? effectiveOptions;
434
+ const parentIndex = parentOptions.findIndex(parent => Object.is(parent.value, activePath[colIndex - 1]));
435
+ focusColumnButton(colIndex - 1, parentIndex);
436
+ activePath = parentPath;
437
+ }
438
+ }
439
+
440
+ $effect(() => {
441
+ // 打开/关闭都开启新的焦点意图,关闭再重开的旧完成不得抢焦点。
442
+ void open;
443
+ focusSeq += 1;
444
+ });
131
445
  </script>
132
446
 
133
447
  <div class={cn('svadmin-u-d89972fe17d6 svadmin-u-6da6a3c3f741', className)} data-testid="cascader">
134
448
  <Popover.Root bind:open>
135
- <Popover.Trigger class="svadmin-u-6da6a3c3f741">
449
+ <div class="cascader-control">
450
+ <Popover.Trigger id={triggerId} {disabled} class="svadmin-u-6da6a3c3f741">
136
451
  {#snippet child({ props })}
137
452
  <button
138
453
  type="button"
139
454
  {...props}
455
+ aria-label={ariaLabel}
456
+ aria-controls={open ? panelId : undefined}
457
+ aria-describedby={describedby}
458
+ data-invalid={invalid || undefined}
459
+ bind:this={triggerElement}
140
460
  {disabled}
141
461
  class={cn(
142
462
  'svadmin-u-60fbb7713999 svadmin-u-968a1e649bad svadmin-u-6da6a3c3f741 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-421ac2be5045 svadmin-u-ca6bcd4b6f3f svadmin-u-e5795dad4d22 svadmin-u-e6f9e383a762 svadmin-u-0e17f2bd9074 svadmin-u-ec0091ee009b svadmin-u-359090c2d529 svadmin-u-582e6ef4b245 svadmin-u-9c24ab70af61 svadmin-u-674ec2b09890 svadmin-u-608dd26cd5ba svadmin-u-80b9d0ae125f svadmin-u-6b22a22a9752 svadmin-u-5f533b3a7de7 svadmin-u-b29d8adbad2e svadmin-u-2eba0d65d059',
@@ -148,58 +468,97 @@
148
468
  </span>
149
469
 
150
470
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-44ee8ba0a421 svadmin-u-012fbd121f37 svadmin-u-f58b02572ab2 svadmin-u-bfa603190748">
151
- {#if allowClear && displayLabel && !disabled}
152
- <span
153
- role="button"
154
- tabindex="0"
155
- class="svadmin-u-ea7b2e9e070e svadmin-u-34516836730d svadmin-u-de8350a3bbad"
156
- onclick={clearAll}
157
- onkeydown={(e) => {
158
- if (e.key === 'Enter') clearAll(e as never);
159
- }}
160
- >
161
- <X class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" />
162
- </span>
163
- {/if}
164
471
  <ChevronsUpDown class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-0b8c506a0596" />
165
472
  </div>
166
473
  </button>
167
474
  {/snippet}
168
475
  </Popover.Trigger>
476
+ {#if allowClear && displayLabel && !disabled}
477
+ <Button type="button" variant="ghost" size="icon-sm"
478
+ aria-label={i18n.t('common.clearSelection')} title={i18n.t('common.clearSelection')} onclick={clearAll}>
479
+ <X class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" />
480
+ </Button>
481
+ {/if}
482
+ </div>
169
483
 
170
- <Popover.Content class="svadmin-u-23e1f628d033 svadmin-u-68fe531347b9 svadmin-u-8a539c7fe216 svadmin-u-06bbb43166db svadmin-u-ca6bcd4b6f3f svadmin-u-18049387f0af" align="start">
484
+ <Popover.Content id={panelId} role="dialog" aria-labelledby={triggerId}
485
+ onOpenAutoFocus={handleOpenAutoFocus} onCloseAutoFocus={handleCloseAutoFocus}
486
+ class="svadmin-cascader-panel svadmin-u-23e1f628d033 svadmin-u-68fe531347b9 svadmin-u-8a539c7fe216 svadmin-u-06bbb43166db svadmin-u-ca6bcd4b6f3f svadmin-u-18049387f0af" align="start">
487
+ {#snippet child({ props, wrapperProps })}
488
+ <div {...wrapperProps}>
489
+ <div {...props} id={panelId} bind:this={panelElement} onfocusin={handlePanelFocusIn}>
171
490
  <div class="svadmin-u-60fbb7713999 svadmin-u-3746131ec018 svadmin-u-e783642739e3 svadmin-u-8aee2b07b47d svadmin-u-1384f66f41d0">
172
491
  {#each columns as columnOptions, colIndex (colIndex)}
173
- <div class="svadmin-u-84789e8a20cd svadmin-u-1734f23deafe svadmin-u-8aee2b07b47d svadmin-u-92bf82f493b1 svadmin-u-eb6a3cef9686 svadmin-u-e2eedc5718f0">
492
+ <div data-cascader-column={colIndex} class="svadmin-u-84789e8a20cd svadmin-u-1734f23deafe svadmin-u-8aee2b07b47d svadmin-u-92bf82f493b1 svadmin-u-eb6a3cef9686 svadmin-u-e2eedc5718f0">
174
493
  {#each columnOptions as option (option.value)}
175
494
  {@const isSelectedInPath = activePath[colIndex] === option.value}
176
- {@const hasChildren = Boolean(option.children && option.children.length > 0 && !option.isLeaf)}
495
+ {@const optionHasChildren = hasChildren(option)}
496
+ {@const unavailable = isOptionDisabled(colIndex, option)}
177
497
  <button
178
498
  type="button"
499
+ disabled={unavailable}
179
500
  class={cn(
180
501
  'svadmin-u-60fbb7713999 svadmin-u-6da6a3c3f741 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-421ac2be5045 svadmin-u-0b91436debbd svadmin-u-ec0091ee009b svadmin-u-359090c2d529 svadmin-u-2eba0d65d059 svadmin-u-ceb69a6b0e5f',
181
502
  isSelectedInPath
182
503
  ? 'svadmin-u-375dc44df6e9 svadmin-u-20aaf08a7ed1 svadmin-u-2689f3958069'
183
504
  : 'svadmin-u-68646cdcc246 svadmin-u-d4108abe6359',
184
- option.disabled && 'svadmin-u-0b8c506a0596 svadmin-u-29b733e4c162 svadmin-u-a4326536b8f5'
505
+ unavailable && 'svadmin-u-0b8c506a0596 svadmin-u-29b733e4c162 svadmin-u-a4326536b8f5'
185
506
  )}
186
507
  onclick={() => handleSelectOption(colIndex, option)}
508
+ onkeydown={(event) => handleOptionKeydown(event, colIndex, option)}
187
509
  >
188
510
  <span class="svadmin-u-f283ea9bea0e svadmin-u-36e579c0b41c">{option.label}</span>
189
- {#if hasChildren}
511
+ {#if optionHasChildren}
190
512
  <ChevronRight class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-012fbd121f37 svadmin-u-f2868c227fcd svadmin-u-f58b02572ab2" />
191
513
  {:else if isSelectedInPath && value && value[colIndex] === option.value}
192
514
  <Check class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-012fbd121f37 svadmin-u-20aaf08a7ed1 svadmin-u-f58b02572ab2" />
193
515
  {/if}
194
516
  </button>
195
517
  {:else}
196
- <div class="svadmin-u-cb11fec3bb46 svadmin-u-ca6bf63030aa svadmin-u-359090c2d529 svadmin-u-bfa603190748">
197
- {i18n.t('common.noData', undefined) ?? '无数据'}
198
- </div>
518
+ {@const parent = columnParent(colIndex)}
519
+ {@const parentKey = pathId(activePath.slice(0, colIndex))}
520
+ {#if parent && loadingValues.has(parentKey)}
521
+ <div role="status" class="svadmin-u-cb11fec3bb46 svadmin-u-ca6bf63030aa svadmin-u-359090c2d529 svadmin-u-bfa603190748">
522
+ {i18n.t('common.loading', undefined) ?? '加载中...'}
523
+ </div>
524
+ {:else if parent && loadErrors.has(parentKey)}
525
+ <div role="alert" class="svadmin-u-cb11fec3bb46 svadmin-u-ca6bf63030aa svadmin-u-359090c2d529 svadmin-u-bfa603190748">
526
+ <span>{i18n.t('tree.loadFailed', undefined) ?? '子节点加载失败'}</span>
527
+ {#if loadChildren}
528
+ <Button type="button" variant="ghost" size="sm"
529
+ onclick={(event) => retryColumn(colIndex, event)}>
530
+ {i18n.t('common.retry', undefined) ?? '重试'}
531
+ </Button>
532
+ {/if}
533
+ </div>
534
+ {:else}
535
+ <div class="svadmin-u-cb11fec3bb46 svadmin-u-ca6bf63030aa svadmin-u-359090c2d529 svadmin-u-bfa603190748">
536
+ {i18n.t('common.noData', undefined) ?? '无数据'}
537
+ </div>
538
+ {/if}
199
539
  {/each}
200
540
  </div>
201
541
  {/each}
202
542
  </div>
543
+ </div>
544
+ </div>
545
+ {/snippet}
203
546
  </Popover.Content>
204
547
  </Popover.Root>
205
548
  </div>
549
+
550
+ <style>
551
+ .cascader-control {
552
+ display: flex;
553
+ align-items: center;
554
+ gap: 4px;
555
+ min-inline-size: 0;
556
+ }
557
+ .cascader-control > :global(button[data-popover-trigger]) {
558
+ flex: 1;
559
+ min-inline-size: 0;
560
+ }
561
+ :global(.svadmin-cascader-panel) {
562
+ max-inline-size: calc(100vw - 32px);
563
+ }
564
+ </style>
@@ -2,10 +2,15 @@ export interface CascaderOption {
2
2
  value: string | number;
3
3
  label: string;
4
4
  children?: CascaderOption[];
5
+ hasChildren?: boolean;
5
6
  disabled?: boolean;
6
7
  isLeaf?: boolean;
7
8
  }
8
9
  interface Props {
10
+ id?: string;
11
+ ariaLabel?: string;
12
+ describedby?: string;
13
+ invalid?: boolean;
9
14
  options?: CascaderOption[];
10
15
  value?: (string | number)[];
11
16
  separator?: string;
@@ -13,9 +18,10 @@ interface Props {
13
18
  changeOnSelect?: boolean;
14
19
  disabled?: boolean;
15
20
  allowClear?: boolean;
21
+ loadChildren?: (node: CascaderOption) => Promise<CascaderOption[]>;
16
22
  class?: string;
17
23
  onchange?: (value: (string | number)[] | undefined, selectedOptions: CascaderOption[]) => void;
18
24
  }
19
- declare const Cascader: import("svelte").Component<Props, {}, "value">;
25
+ declare const Cascader: import("svelte").Component<Props, {}, "value" | "options">;
20
26
  type Cascader = ReturnType<typeof Cascader>;
21
27
  export default Cascader;
@@ -8,7 +8,7 @@
8
8
  disabled?: boolean;
9
9
  required?: boolean;
10
10
  invalid?: boolean;
11
- describedby?: string;
11
+ describedby?: string | undefined;
12
12
  ariaLabel?: string;
13
13
  class?: string;
14
14
  onchange?: (value: string) => void;
@@ -5,7 +5,7 @@ interface Props {
5
5
  disabled?: boolean;
6
6
  required?: boolean;
7
7
  invalid?: boolean;
8
- describedby?: string;
8
+ describedby?: string | undefined;
9
9
  ariaLabel?: string;
10
10
  class?: string;
11
11
  onchange?: (value: string) => void;