@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
@@ -19,6 +19,7 @@
19
19
  let { headingLevel = 'h1' }: { headingLevel?: 'h1' | 'h2' } = $props();
20
20
  const i18n = useTranslation();
21
21
  const adminContext = captureAdminContext();
22
+ const controlId = $props.id();
22
23
 
23
24
  const identity = useGetIdentity();
24
25
  const notification = useNotification();
@@ -33,6 +34,7 @@
33
34
  let profileSaving = $state(false);
34
35
  let profileError = $state('');
35
36
  let avatarUploading = $state(false);
37
+ let avatarError = $state('');
36
38
  let fileInput = $state<HTMLInputElement>();
37
39
  let currentPassword = $state('');
38
40
  let newPassword = $state('');
@@ -46,6 +48,7 @@
46
48
  profileSaving = false;
47
49
  profileError = '';
48
50
  avatarUploading = false;
51
+ avatarError = '';
49
52
  currentPassword = '';
50
53
  newPassword = '';
51
54
  confirmPassword = '';
@@ -62,6 +65,10 @@
62
65
  && scopedTenantIdentity === tenantIdentity;
63
66
  }
64
67
 
68
+ function failureMessage(): string {
69
+ return i18n.t('common.operationFailed');
70
+ }
71
+
65
72
  $effect(() => {
66
73
  void authProvider;
67
74
  void tenantIdentity;
@@ -93,11 +100,11 @@
93
100
  identity.refetch();
94
101
  notification.success(i18n.t('common.updateSuccess'), 3000);
95
102
  } else {
96
- profileError = result.error?.message ?? i18n.t('common.operationFailed');
103
+ profileError = failureMessage();
97
104
  }
98
- } catch (e) {
105
+ } catch {
99
106
  if (isActiveScope(epoch, provider, scopedTenantIdentity)) {
100
- profileError = e instanceof Error ? e.message : String(e);
107
+ profileError = failureMessage();
101
108
  }
102
109
  } finally {
103
110
  if (isActiveScope(epoch, provider, scopedTenantIdentity)) profileSaving = false;
@@ -105,15 +112,22 @@
105
112
  }
106
113
 
107
114
  async function handleAvatarChange(e: Event) {
108
- const file = (e.target as HTMLInputElement).files?.[0];
115
+ const input = e.target as HTMLInputElement;
116
+ const file = input.files?.[0];
109
117
  const provider = authProvider;
110
- if (!file || !provider?.updateProfile) return;
118
+ input.value = '';
119
+ if (!file || avatarUploading || !provider?.updateProfile) return;
111
120
  const epoch = scopeEpoch;
112
121
  const scopedTenantIdentity = tenantIdentity;
113
122
  avatarUploading = true;
123
+ avatarError = '';
114
124
  try {
115
125
  const result = await provider.updateProfile({ avatar: file });
116
- if (isActiveScope(epoch, provider, scopedTenantIdentity) && result.success) identity.refetch();
126
+ if (!isActiveScope(epoch, provider, scopedTenantIdentity)) return;
127
+ if (result.success) identity.refetch();
128
+ else avatarError = failureMessage();
129
+ } catch {
130
+ if (isActiveScope(epoch, provider, scopedTenantIdentity)) avatarError = failureMessage();
117
131
  } finally {
118
132
  if (isActiveScope(epoch, provider, scopedTenantIdentity)) avatarUploading = false;
119
133
  }
@@ -140,7 +154,7 @@
140
154
  newPassword = '';
141
155
  confirmPassword = '';
142
156
  } else {
143
- pwError = result.error?.message ?? i18n.t('common.operationFailed');
157
+ pwError = failureMessage();
144
158
  }
145
159
  }
146
160
 
@@ -168,6 +182,14 @@
168
182
  <Loader2 class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-afbdd13a380e" />
169
183
  {i18n.t('common.loading')}
170
184
  </div>
185
+ {:else if identity.error}
186
+ <Alert.Root variant="destructive">
187
+ <AlertCircle class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3" />
188
+ <Alert.Description class="svadmin-u-60fbb7713999 svadmin-u-77a2a20e90d4">
189
+ <span>{failureMessage()}</span>
190
+ <Button size="sm" variant="outline" onclick={() => { void identity.refetch(); }}>{i18n.t('common.retry')}</Button>
191
+ </Alert.Description>
192
+ </Alert.Root>
171
193
  {:else if identity.data}
172
194
  <div class="svadmin-u-60fbb7713999 svadmin-u-60541e1e26f8 svadmin-u-0d304f904cb0">
173
195
  <!-- Avatar -->
@@ -188,6 +210,8 @@
188
210
  class="svadmin-u-da4dbfbc4fdc svadmin-u-7b7df0449b80 svadmin-u-5f22e64f2282 svadmin-u-53bb3a280599 svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-86843cf1e227 svadmin-u-7065497e1ca0 svadmin-u-181f3d6c9821 svadmin-u-67d6184a0024 svadmin-u-34516836730d"
189
211
  onclick={() => fileInput?.click()}
190
212
  disabled={avatarUploading}
213
+ aria-label={i18n.t('account.uploadAvatar')}
214
+ title={i18n.t('account.uploadAvatar')}
191
215
  >
192
216
  {#if avatarUploading}
193
217
  <Loader2 class="svadmin-u-cd0d9c512cdc svadmin-u-72470489ff4e svadmin-u-72a4c7cdeef7 svadmin-u-afbdd13a380e" />
@@ -207,11 +231,12 @@
207
231
 
208
232
  <!-- Info -->
209
233
  <div class="svadmin-u-6ed543e2fbbb svadmin-u-36e579c0b41c svadmin-u-7e0b7cdf1a94">
234
+ {#if avatarError}<p role="alert">{avatarError}</p>{/if}
210
235
  {#if editingProfile}
211
236
  <div class="svadmin-u-6ed543e2fbbb svadmin-u-2472e9b81a97">
212
237
  <div>
213
- <Label for="edit-name" class="svadmin-u-359090c2d529 svadmin-u-bfa603190748">{i18n.t('profile.name')}</Label>
214
- <Input id="edit-name" bind:value={editName} class="svadmin-u-b6b02c0ebef6" />
238
+ <Label for={`${controlId}-edit-name`} class="svadmin-u-359090c2d529 svadmin-u-bfa603190748">{i18n.t('profile.name')}</Label>
239
+ <Input id={`${controlId}-edit-name`} bind:value={editName} class="svadmin-u-b6b02c0ebef6" />
215
240
  </div>
216
241
  {#if profileError}
217
242
  <p class="svadmin-u-359090c2d529 svadmin-u-811148b13d1e">{profileError}</p>
@@ -281,22 +306,22 @@
281
306
  {/if}
282
307
 
283
308
  <PasswordInput
284
- id="current-password"
309
+ id={`${controlId}-current-password`}
285
310
  label={i18n.t('profile.currentPassword')}
286
311
  bind:value={currentPassword}
287
312
  autocomplete="current-password"
288
313
  />
289
314
 
290
315
  <PasswordInput
291
- id="new-password"
292
- label={i18n.t('auth.password')}
316
+ id={`${controlId}-new-password`}
317
+ label={i18n.t('profile.newPassword')}
293
318
  bind:value={newPassword}
294
319
  autocomplete="new-password"
295
320
  showStrength
296
321
  />
297
322
 
298
323
  <PasswordInput
299
- id="confirm-new-password"
324
+ id={`${controlId}-confirm-new-password`}
300
325
  label={i18n.t('auth.confirmPassword')}
301
326
  bind:value={confirmPassword}
302
327
  autocomplete="new-password"
@@ -1,4 +1,5 @@
1
1
  <script lang="ts" generics="T extends string">
2
+ import { useTranslation } from '@svadmin/core/i18n';
2
3
  import { cn } from '../utils.js';
3
4
 
4
5
  export interface RadioGroupOption<T extends string = string> {
@@ -11,7 +12,7 @@
11
12
  interface Props<T extends string = string> {
12
13
  options: readonly RadioGroupOption<T>[];
13
14
  value?: T | null;
14
- name?: string;
15
+ name?: string | undefined;
15
16
  legend?: string;
16
17
  ariaLabel?: string;
17
18
  disabled?: boolean;
@@ -35,18 +36,214 @@
35
36
  class: className = '',
36
37
  onchange,
37
38
  }: Props<T> = $props();
39
+ const instanceId = $props.id();
40
+ const i18n = useTranslation();
41
+ let groupRoot: HTMLFieldSetElement;
42
+ // 原生 reset 恢复的是初始选中值,而不是最后一次用户选择。值一旦变化就
43
+ // 无法再推断初始状态,因此只在挂载后的首个值快照一次;初始 null 表示
44
+ // 恢复为未选。
45
+ let resetValue: T | null = null;
46
+ let resetCaptured = false;
47
+ let pendingNativeReset: { event: Event; form: HTMLFormElement } | undefined;
48
+ const enabledOptions = $derived(options.filter(option => !option.disabled));
49
+ const tabValue = $derived(enabledOptions.find(option => option.value === value)?.value ?? enabledOptions[0]?.value);
50
+
51
+ function syncChecked(): void {
52
+ if (!groupRoot) return;
53
+ for (const input of groupRoot.querySelectorAll<HTMLInputElement>('input[type="radio"]')) {
54
+ input.checked = input.value === value;
55
+ }
56
+ }
57
+
58
+ function flushNativeReset(): void {
59
+ const reset = pendingNativeReset;
60
+ if (!reset || reset.event.eventPhase !== Event.NONE) return;
61
+ pendingNativeReset = undefined;
62
+ if (groupRoot?.form !== reset.form) return;
63
+ // 与 FileUpload 保持同一原生 reset 约定:先等事件分发结束,再读取
64
+ // defaultPrevented。被取消时保留当前值,并修复 happy-dom 在取消前
65
+ // 已清空 checked 的模拟差异;真实浏览器此处是无副作用的重复赋值。
66
+ if (reset.event.defaultPrevented) {
67
+ syncChecked();
68
+ return;
69
+ }
70
+ value = options.some(option => option.value === resetValue) ? resetValue : null;
71
+ syncChecked();
72
+ }
73
+
74
+ $effect(() => {
75
+ const form = groupRoot?.form;
76
+ if (!form) return;
77
+ const reset = (event: Event) => {
78
+ flushNativeReset();
79
+ pendingNativeReset = { event, form };
80
+ // reset 事件先于默认动作;等默认动作完成后才能读取最终取消状态。
81
+ setTimeout(flushNativeReset, 0);
82
+ };
83
+ form.addEventListener('reset', reset);
84
+ return () => {
85
+ form.removeEventListener('reset', reset);
86
+ if (pendingNativeReset?.form === form) pendingNativeReset = undefined;
87
+ };
88
+ });
89
+
90
+ // Svelte 的 checked 只写 DOM property,不会留下 checked 内容属性,因此
91
+ // 浏览器 reset 默认会清空全部选中项。显式同步 defaultChecked,让原生
92
+ // 默认动作与上面缓存的 resetValue 保持一致;后续用户选择不改变该基线。
93
+ $effect(() => {
94
+ const current = value;
95
+ void options;
96
+ if (!groupRoot) return;
97
+ if (!resetCaptured) {
98
+ resetCaptured = true;
99
+ resetValue = current;
100
+ }
101
+ for (const input of groupRoot.querySelectorAll<HTMLInputElement>('input[type="radio"]')) {
102
+ input.defaultChecked = resetValue !== null && input.value === resetValue;
103
+ }
104
+ });
105
+
106
+ // 空 name 的原生 radio 在同一表单内仍属于同一个浏览器分组,不能靠逐个
107
+ // required 隔离实例。未命名组改用组件内的 custom validity,且不生成字段名。
108
+ // 每次重新同步所有输入,避免切换 name/required/disabled、替换选项或父级
109
+ // 清空后残留旧错误;提示复用现有 i18n 的 validation.required。
110
+ $effect(() => {
111
+ // 先读取全部依赖再分支,避免某次提前返回后漏跟踪 required/disabled/
112
+ // options/value,导致后续状态变化不再清除或重建 custom validity。
113
+ const currentName = name;
114
+ const isRequired = required;
115
+ const isDisabled = disabled;
116
+ const currentValue = value;
117
+ const currentOptions = options;
118
+ const message = i18n.t('validation.required');
119
+ const inputs = groupRoot
120
+ ? [...groupRoot.querySelectorAll<HTMLInputElement>('input[type="radio"]')]
121
+ : [];
122
+ const hasSelection = currentOptions.some(option => !option.disabled && option.value === currentValue);
123
+ const firstEnabledIndex = currentOptions.findIndex(option => !option.disabled);
124
+ for (const input of inputs) input.setCustomValidity('');
125
+ if (!groupRoot || currentName || !isRequired || isDisabled || hasSelection) return;
126
+ if (firstEnabledIndex >= 0) inputs[firstEnabledIndex]?.setCustomValidity(message);
127
+ });
38
128
 
39
129
  function select(option: RadioGroupOption<T>): void {
40
130
  if (disabled || option.disabled) return;
131
+ // reset 的最终状态要等事件分发结束才能判定;若同一任务内用户已经重新
132
+ // 选择,则最后一次选择优先,作废尚未冲刷的 pending reset。即使新选择
133
+ // 与 reset 前的值相同,也必须先作废并同步 DOM,否则延迟冲刷会用初始值
134
+ // 覆盖它,reset 的默认动作也可能留下与绑定值不一致的 checked。
135
+ const hadPendingReset = pendingNativeReset !== undefined;
136
+ pendingNativeReset = undefined;
41
137
  if (value !== option.value) {
42
138
  value = option.value;
43
139
  onchange?.(option.value);
44
140
  }
141
+ if (hadPendingReset) syncChecked();
142
+ }
143
+
144
+ const focusableSelector = [
145
+ 'a[href]',
146
+ 'button',
147
+ 'input',
148
+ 'select',
149
+ 'textarea',
150
+ 'summary',
151
+ '[tabindex]',
152
+ '[contenteditable]:not([contenteditable="false"])',
153
+ ].join(',');
154
+
155
+ function isContentEditableHost(element: HTMLElement): boolean {
156
+ const attribute = element.getAttribute('contenteditable');
157
+ const editable = element.isContentEditable || (attribute !== null && attribute.toLowerCase() !== 'false');
158
+ return editable && !element.parentElement?.isContentEditable;
159
+ }
160
+
161
+ // 与现有 UI focus helper 保持同一判定:排除原生禁用、inert/隐藏区域和
162
+ // 不可见元素,并把 contenteditable 宿主视为真实 Tab 停靠点。aria-disabled
163
+ // 和 aria-hidden 不等于原生不可聚焦,不能据此跳过宿主真实的 Tab 停靠点。
164
+ function isTabbable(element: HTMLElement): boolean {
165
+ if (element.matches(':disabled')) return false;
166
+ if (element.closest('[hidden], [inert]')) return false;
167
+ if (element.hasAttribute('tabindex') && element.tabIndex < 0) return false;
168
+ if (!element.hasAttribute('tabindex') && !isContentEditableHost(element) && element.tabIndex < 0) return false;
169
+ return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
170
+ }
171
+
172
+ // 只有非空 name 才参与原生命名分组;name="" 与省略 name 语义一致。
173
+ function hasNonEmptyName(input: HTMLInputElement): boolean {
174
+ return input.name !== '';
175
+ }
176
+
177
+ function tabbableSequence(form: HTMLFormElement): HTMLElement[] {
178
+ // 只取零序 Tab 停靠点。正 tabindex 元素已在文档起点先访问,不能当作
179
+ // 当前组之后的下一个停靠点;contenteditable 宿主即使 tabIndex 报告 -1
180
+ // 也仍在浏览器 Tab 顺序中。范围收敛到当前表单,不重写整页焦点顺序。
181
+ return [...form.querySelectorAll<HTMLElement>(focusableSelector)]
182
+ .filter(element => (element.tabIndex === 0
183
+ || (!element.hasAttribute('tabindex') && isContentEditableHost(element)))
184
+ && isTabbable(element));
185
+ }
186
+
187
+ function handleGroupTab(event: KeyboardEvent): void {
188
+ // WebKit 会把同一表单内空 name 的 radio 视为同一原生组,并在 Tab 时
189
+ // 跳过后续未命名 RadioGroup。只有同表单存在多个未命名实例时才接管,
190
+ // 且只接管确实落在本组件未命名组上的目标;普通原生 radio、contenteditable、
191
+ // inert 边界交还浏览器。先聚焦成功再取消默认行为,避免半途困住键盘。
192
+ const form = groupRoot.form ?? groupRoot.closest('form');
193
+ const current = document.activeElement;
194
+ if (!form || !(current instanceof HTMLInputElement) || !groupRoot.contains(current)) return;
195
+ const unnamedGroups = [...form.querySelectorAll<HTMLElement>('[data-svadmin-radio-group]')]
196
+ .filter(group => ![...group.querySelectorAll<HTMLInputElement>('input[type="radio"]')].some(hasNonEmptyName));
197
+ if (unnamedGroups.length < 2) return;
198
+ const sequence = tabbableSequence(form);
199
+ const contained = sequence.filter(element => groupRoot.contains(element));
200
+ if (!contained.length) return;
201
+ const firstContained = contained[0];
202
+ const lastContained = contained[contained.length - 1];
203
+ if (!firstContained || !lastContained) return;
204
+ const first = sequence.indexOf(firstContained);
205
+ const last = sequence.indexOf(lastContained);
206
+ if (first < 0 || last < 0) return;
207
+ const target = event.shiftKey ? sequence[first - 1] : sequence[last + 1];
208
+ if (!(target instanceof HTMLInputElement) || target.type !== 'radio') return;
209
+ const targetGroup = target.closest<HTMLElement>('[data-svadmin-radio-group]');
210
+ if (!targetGroup || targetGroup === groupRoot) return;
211
+ if ([...targetGroup.querySelectorAll<HTMLInputElement>('input[type="radio"]')].some(hasNonEmptyName)) return;
212
+ // 保留平台默认的滚动行为,让窄屏上的键盘焦点进入视口。
213
+ target.focus();
214
+ if (document.activeElement === target) event.preventDefault();
215
+ }
216
+
217
+ function handleKeydown(event: KeyboardEvent): void {
218
+ const current = event.currentTarget;
219
+ if (name || disabled || event.altKey || event.ctrlKey || event.metaKey
220
+ || !(current instanceof HTMLInputElement) || current.matches(':disabled')) return;
221
+ if (event.key === 'Tab') {
222
+ handleGroupTab(event);
223
+ return;
224
+ }
225
+ if (!['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
226
+
227
+ // 未命名 radio 的原生方向键可能跨越 fieldset;只在当前组件内选择。
228
+ const inputs = [...groupRoot.querySelectorAll<HTMLInputElement>('input[type="radio"]')]
229
+ .filter(input => !input.matches(':disabled'));
230
+ const index = inputs.indexOf(current);
231
+ if (index < 0) return;
232
+ const rtl = getComputedStyle(groupRoot).direction === 'rtl';
233
+ const forward = event.key === 'ArrowDown' || event.key === (rtl ? 'ArrowLeft' : 'ArrowRight');
234
+ const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? inputs.length - 1
235
+ : (index + (forward ? 1 : -1) + inputs.length) % inputs.length;
236
+ const next = inputs[nextIndex];
237
+ event.preventDefault();
238
+ next?.focus();
239
+ next?.click();
45
240
  }
46
241
  </script>
47
242
 
48
243
  <fieldset
244
+ bind:this={groupRoot}
49
245
  class={cn('svadmin-radio-group', className)}
246
+ data-svadmin-radio-group
50
247
  disabled={disabled}
51
248
  data-invalid={invalid ? 'true' : undefined}
52
249
  aria-describedby={describedby}
@@ -54,8 +251,8 @@
54
251
  >
55
252
  {#if legend}<legend>{legend}</legend>{/if}
56
253
  <div class="svadmin-radio-group__options">
57
- {#each options as option (option.value)}
58
- {@const inputId = `${name ?? 'radio'}-${option.value}`}
254
+ {#each options as option, index (option.value)}
255
+ {@const inputId = `${instanceId}-radio-${index}`}
59
256
  <label class="svadmin-radio-group__option" class:svadmin-radio-group__option--disabled={disabled || option.disabled} for={inputId}>
60
257
  <input
61
258
  id={inputId}
@@ -64,8 +261,10 @@
64
261
  value={option.value}
65
262
  checked={value === option.value}
66
263
  disabled={disabled || option.disabled}
67
- required={required}
264
+ required={required && Boolean(name)}
68
265
  aria-describedby={describedby}
266
+ tabindex={name ? undefined : !disabled && !option.disabled && option.value === tabValue ? 0 : -1}
267
+ onkeydown={handleKeydown}
69
268
  onchange={() => select(option)}
70
269
  />
71
270
  <span class="svadmin-radio-group__copy">
@@ -7,7 +7,7 @@ export interface RadioGroupOption<T extends string = string> {
7
7
  interface Props<T extends string = string> {
8
8
  options: readonly RadioGroupOption<T>[];
9
9
  value?: T | null;
10
- name?: string;
10
+ name?: string | undefined;
11
11
  legend?: string;
12
12
  ariaLabel?: string;
13
13
  disabled?: boolean;
@@ -6,11 +6,11 @@
6
6
  value?: number;
7
7
  count?: number;
8
8
  allowClear?: boolean;
9
- name?: string;
9
+ name?: string | undefined;
10
10
  disabled?: boolean;
11
11
  required?: boolean;
12
12
  invalid?: boolean;
13
- describedby?: string;
13
+ describedby?: string | undefined;
14
14
  ariaLabel?: string;
15
15
  class?: string;
16
16
  onchange?: (value: number) => void;
@@ -66,7 +66,7 @@
66
66
  aria-describedby={describedby}
67
67
  data-invalid={invalid ? 'true' : undefined}
68
68
  >
69
- {#if name}<input type="hidden" {name} value={normalizedValue} required={required} />{/if}
69
+ {#if name}<input type="hidden" {name} value={normalizedValue} required={required} {disabled} />{/if}
70
70
  {#each Array(normalizedCount) as _, index (index)}
71
71
  {@const rating = index + 1}
72
72
  <button
@@ -2,11 +2,11 @@ interface Props {
2
2
  value?: number;
3
3
  count?: number;
4
4
  allowClear?: boolean;
5
- name?: string;
5
+ name?: string | undefined;
6
6
  disabled?: boolean;
7
7
  required?: boolean;
8
8
  invalid?: boolean;
9
- describedby?: string;
9
+ describedby?: string | undefined;
10
10
  ariaLabel?: string;
11
11
  class?: string;
12
12
  onchange?: (value: number) => void;
@@ -13,7 +13,7 @@
13
13
  open?: boolean;
14
14
  recordId?: string | number;
15
15
  onClose?: () => void;
16
- extraSections?: Snippet;
16
+ extraSections?: Snippet | undefined;
17
17
  } = $props();
18
18
  const context = captureAdminContext();
19
19
  const i18n = useTranslation();
@@ -6,7 +6,7 @@ type $$ComponentProps = {
6
6
  open?: boolean;
7
7
  recordId?: string | number;
8
8
  onClose?: () => void;
9
- extraSections?: Snippet;
9
+ extraSections?: Snippet | undefined;
10
10
  };
11
11
  declare const RecordDetailDrawer: import("svelte").Component<$$ComponentProps, {}, "open">;
12
12
  type RecordDetailDrawer = ReturnType<typeof RecordDetailDrawer>;
@@ -36,7 +36,7 @@
36
36
  if (result.success) {
37
37
  onSuccess?.();
38
38
  } else {
39
- error = result.error?.message ?? i18n.t('common.operationFailed');
39
+ error = i18n.t('common.operationFailed');
40
40
  }
41
41
  }
42
42
  </script>
@@ -69,6 +69,7 @@
69
69
  const scope = $derived({
70
70
  resource,
71
71
  contract: binding.resource,
72
+ providerName: binding.dataProviderName,
72
73
  provider: context.providers?.[binding.dataProviderName],
73
74
  meta: JSON.stringify(binding.meta),
74
75
  access: context.accessControlProvider,
@@ -80,6 +81,24 @@
80
81
  filters,
81
82
  sorters,
82
83
  });
84
+ function sameScope(left: typeof scope | undefined, right: typeof scope | undefined): boolean {
85
+ if (!left || !right) return left === right;
86
+ return left.resource === right.resource
87
+ && left.contract === right.contract
88
+ && left.providerName === right.providerName
89
+ && left.provider === right.provider
90
+ && left.meta === right.meta
91
+ && left.access === right.access
92
+ && left.authProvider === right.authProvider
93
+ && left.tenant === right.tenant
94
+ && left.auth === right.auth
95
+ && left.optionLabel === right.optionLabel
96
+ && left.normalizedFetchSize === right.normalizedFetchSize
97
+ && left.filters === right.filters
98
+ && left.sorters === right.sorters;
99
+ }
100
+ let observedScope = $state.raw(untrack(() => scope));
101
+ let observedSearchable = $state(untrack(() => searchable));
83
102
  let valueInvalidated = $state(false);
84
103
  const selectedValues = $derived(allowed && !valueInvalidated ? normalizedValues(value) : []);
85
104
  const select = useSelect(definedReactiveOptions({
@@ -98,12 +117,14 @@
98
117
  get sorters() { return sorters; },
99
118
  get filters() { return [...filters, ...searchFilters]; },
100
119
  get defaultValue() { return selectedValues; },
101
- get queryOptions() { return { enabled: allowed }; },
120
+ // 查询会先于清理 effect 重算,旧草稿不得先发送给新作用域。
121
+ get queryOptions() {
122
+ return { enabled: allowed && sameScope(observedScope, scope) && observedSearchable === searchable };
123
+ },
102
124
  get projectionKey() { return `relation-picker:${String(optionLabel)}:id`; },
103
125
  }));
104
126
 
105
127
  let loaded = $state.raw<{ scope: typeof scope; search: string; options: RelationPickerOption[] }>();
106
- let observedScope = untrack(() => scope);
107
128
  let observedValue = untrack(() => value);
108
129
  let observedAllowed = untrack(() => allowed);
109
130
  let draftScope = $state.raw<typeof scope>();
@@ -123,7 +144,7 @@
123
144
  const currentScope = scope;
124
145
  const previous = untrack(() => loaded);
125
146
  const options = new Map<string | number, RelationPickerOption>(
126
- page > 1 && previous?.scope === currentScope && previous.search === appliedSearch
147
+ page > 1 && previous && sameScope(previous.scope, currentScope) && previous.search === appliedSearch
127
148
  ? previous.options.map(option => [option.value, option]) : [],
128
149
  );
129
150
  for (const option of data.options) options.set(option.value, option);
@@ -133,7 +154,7 @@
133
154
  const availableOptions = $derived.by(() => {
134
155
  if (!allowed || select.isError) return [];
135
156
  const options = new Map<string | number, RelationPickerOption>();
136
- if (loaded?.scope === scope && loaded.search === appliedSearch) {
157
+ if (loaded && sameScope(loaded.scope, scope) && loaded.search === appliedSearch) {
137
158
  for (const option of loaded.options) options.set(option.value, option);
138
159
  }
139
160
  for (const option of select.options) options.set(option.value, option);
@@ -152,7 +173,7 @@
152
173
  }
153
174
 
154
175
  function toggle(item: string | number): void {
155
- if (disabled || !allowed || !open || draftScope !== scope || select.isError
176
+ if (disabled || !allowed || !open || !sameScope(draftScope, scope) || select.isError
156
177
  || select.isFetching || searchText !== appliedSearch) return;
157
178
  if (!multiple) {
158
179
  draftValues = [item];
@@ -165,16 +186,17 @@
165
186
  }
166
187
 
167
188
  function commit(): void {
168
- if (disabled || !allowed || !open || draftScope !== scope || select.isError
189
+ if (disabled || !allowed || !open || !sameScope(draftScope, scope) || select.isError
169
190
  || select.isFetching || searchText !== appliedSearch) return;
170
191
  const next = [...draftValues];
171
192
  onchange?.(multiple ? next : next[0] ?? null);
172
- open = false;
173
- resetSearch();
193
+ cancel();
174
194
  }
175
195
 
176
196
  function cancel(): void {
177
197
  open = false;
198
+ draftValues = [];
199
+ draftScope = undefined;
178
200
  resetSearch();
179
201
  }
180
202
 
@@ -198,7 +220,7 @@
198
220
  clearTimeout(timer);
199
221
  const origin = scope;
200
222
  timer = setTimeout(() => {
201
- if (origin !== scope || disabled || !allowed || !open) return;
223
+ if (!sameScope(origin, scope) || disabled || !allowed || !open) return;
202
224
  appliedSearch = searchText;
203
225
  page = 1;
204
226
  loaded = undefined;
@@ -210,13 +232,19 @@
210
232
  && searchText === appliedSearch) page += 1;
211
233
  }
212
234
 
235
+ $effect.pre(() => {
236
+ if (observedSearchable === searchable) return;
237
+ observedSearchable = searchable;
238
+ resetSearch();
239
+ });
240
+
213
241
  $effect.pre(() => {
214
242
  const currentScope = scope;
215
243
  const currentValue = value;
216
244
  const currentAllowed = allowed;
217
245
  const currentDisabled = disabled;
218
246
  untrack(() => {
219
- if (observedScope !== currentScope || (observedAllowed && !currentAllowed)) {
247
+ if (!sameScope(observedScope, currentScope) || (observedAllowed && !currentAllowed)) {
220
248
  observedScope = currentScope;
221
249
  valueInvalidated = true;
222
250
  draftValues = [];
@@ -261,7 +289,7 @@
261
289
  {/if}
262
290
  </div>
263
291
 
264
- <Dialog.Root bind:open>
292
+ <Dialog.Root bind:open onOpenChange={(next) => { if (!next) cancel(); }}>
265
293
  <Dialog.Content class="svadmin-relation-picker__dialog" aria-label={title ?? i18n.t('common.selectOption')}>
266
294
  <Dialog.Header>
267
295
  <Dialog.Title>{title ?? i18n.t('common.selectOption')}</Dialog.Title>
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { Action } from '@svadmin/core';
5
5
  import { captureAdminContext, useCan } from '@svadmin/core';
6
+ import { useTranslation } from '@svadmin/core/i18n';
6
7
  import type { Snippet } from 'svelte';
7
8
  import DataState from './content/DataState.svelte';
8
9
 
@@ -23,6 +24,7 @@
23
24
  }: Props = $props();
24
25
 
25
26
  const adminContext = captureAdminContext();
27
+ const i18n = useTranslation();
26
28
  const resource = $derived(adminContext.getResource(resourceName));
27
29
  const accessControlEnabled = $derived(Boolean(adminContext.accessControlProvider));
28
30
  const actionAllowedByResource = $derived(
@@ -58,7 +60,10 @@
58
60
  && (!accessControlEnabled || permission.allowed)
59
61
  && (!requireSourceRead || !accessControlEnabled || sourceReadPermission.allowed),
60
62
  );
61
- const denialReason = $derived(permission.reason ?? sourceReadPermission.reason);
63
+ const permissionError = $derived(permission.isError || sourceReadPermission.isError);
64
+ const denialDescription = $derived(
65
+ permissionError ? i18n.t('common.permissionUnavailable') : i18n.t('common.permissionDenied'),
66
+ );
62
67
  </script>
63
68
 
64
69
  {#if permissionPending}
@@ -66,5 +71,5 @@
66
71
  {:else if allowed}
67
72
  {@render children()}
68
73
  {:else}
69
- <DataState state="forbidden" {...definedOptions({ "description": denialReason })} />
74
+ <DataState state={permissionError ? 'error' : 'forbidden'} description={denialDescription} />
70
75
  {/if}