@svadmin/ui 0.86.0 → 0.88.1

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 (42) hide show
  1. package/README.md +3 -1
  2. package/dist/app.css +188 -5
  3. package/dist/app.theme.css +188 -5
  4. package/dist/component-registry.d.ts +1 -1
  5. package/dist/component-registry.js +2 -2
  6. package/dist/components/ApprovalActionCard.svelte +54 -31
  7. package/dist/components/ApprovalCenter.svelte +73 -31
  8. package/dist/components/AuthPageShell.svelte +13 -2
  9. package/dist/components/AutoTable.svelte +13 -3
  10. package/dist/components/CreatePage.svelte +22 -15
  11. package/dist/components/CreatePage.svelte.d.ts +4 -0
  12. package/dist/components/EditPage.svelte +23 -16
  13. package/dist/components/EditPage.svelte.d.ts +4 -0
  14. package/dist/components/ListPage.svelte +20 -14
  15. package/dist/components/ListPage.svelte.d.ts +2 -0
  16. package/dist/components/LoginPage.svelte +27 -11
  17. package/dist/components/LoginPage.svelte.d.ts +4 -0
  18. package/dist/components/NotificationsSettings.svelte +16 -8
  19. package/dist/components/PagePattern.svelte +2 -2
  20. package/dist/components/PagePattern.svelte.d.ts +1 -1
  21. package/dist/components/PasswordInput.svelte +36 -17
  22. package/dist/components/PasswordInput.svelte.d.ts +3 -0
  23. package/dist/components/ShowPage.svelte +20 -21
  24. package/dist/components/account/MemberDirectory.svelte +17 -4
  25. package/dist/components/account/SecurityLogPage.svelte +8 -1
  26. package/dist/components/content/AuditTimeline.svelte +32 -4
  27. package/dist/components/content/AuditTimeline.svelte.d.ts +9 -0
  28. package/dist/components/content/DataState.svelte +5 -4
  29. package/dist/components/content/DataState.svelte.d.ts +1 -1
  30. package/dist/components/content/FilterToolbar.svelte +7 -1
  31. package/dist/components/content/WorkspaceLayout.svelte +3 -1
  32. package/dist/components/content/WorkspaceLayout.svelte.d.ts +1 -0
  33. package/dist/components/network/TeamCrewTablePage.svelte +8 -1
  34. package/dist/components/network/UserCardsNFTPage.svelte +3 -1
  35. package/dist/components/profile/ProjectsGrid.svelte +1 -1
  36. package/dist/components/profile/TeamsShowcase.svelte +1 -1
  37. package/dist/design-contract.d.ts +1 -1
  38. package/dist/design-contract.js +1 -1
  39. package/dist/page-chrome.css +3 -3
  40. package/dist/recipes.d.ts +102 -69
  41. package/dist/recipes.js +40 -2
  42. package/package.json +1 -1
@@ -8,6 +8,10 @@ interface Props {
8
8
  columns?: 1 | 2 | 3 | 4;
9
9
  canDelete?: boolean;
10
10
  headerActions?: Snippet;
11
+ formActions?: Snippet<[{
12
+ isLoading: boolean;
13
+ onSubmit: () => void;
14
+ }]>;
11
15
  onSuccess?: () => void;
12
16
  class?: string;
13
17
  }
@@ -9,6 +9,7 @@
9
9
  import PageHeader from './PageHeader.svelte';
10
10
  import AutoTable from './AutoTable.svelte';
11
11
  import type { BatchSelection } from './table-contract';
12
+ import type { SavedListViewProvider } from './saved-list-views.js';
12
13
  import CreateButton from './buttons/CreateButton.svelte';
13
14
 
14
15
  interface Props {
@@ -19,6 +20,7 @@
19
20
  /** Passthrough: enable row selection checkboxes */
20
21
  selectable?: boolean;
21
22
  allowSelectAllMatching?: boolean;
23
+ savedViewProvider?: SavedListViewProvider;
22
24
  /** Passthrough: custom header actions */
23
25
  headerActions?: Snippet;
24
26
  /** Optional status tabs slot above filter/table */
@@ -45,6 +47,7 @@
45
47
  canCreate,
46
48
  selectable,
47
49
  allowSelectAllMatching,
50
+ savedViewProvider,
48
51
  headerActions,
49
52
  statusTabs,
50
53
  filterToolbar,
@@ -60,7 +63,7 @@
60
63
  const showCreate = $derived(canCreate ?? resource.canCreate !== false);
61
64
  </script>
62
65
 
63
- <div class="svadmin-u-3e7ce58d64fa {className}">
66
+ <div class="svadmin-u-3e7ce58d64fa {className}" data-svadmin-list-page={resourceName}>
64
67
  <PageHeader title={pageTitle}>
65
68
  {#snippet actions()}
66
69
  {#if showCreate}
@@ -73,26 +76,29 @@
73
76
  </PageHeader>
74
77
 
75
78
  {#if statusTabs}
76
- <div>
79
+ <div data-svadmin-list-page-status-tabs>
77
80
  {@render statusTabs()}
78
81
  </div>
79
82
  {/if}
80
83
 
81
84
  {#if filterToolbar}
82
- <div>
85
+ <div data-svadmin-list-page-filter-toolbar>
83
86
  {@render filterToolbar()}
84
87
  </div>
85
88
  {/if}
86
89
 
87
- <AutoTable
88
- {rendering}
89
- {resourceName}
90
- showHeader={false}
91
- {...definedOptions({ "selectable": selectable })}
92
- {...definedOptions({ allowSelectAllMatching })}
93
- {...definedOptions({ batchActions })}
94
- {...definedOptions({ "defaultCellRenderer": cellRenderer })}
95
- {...definedOptions({ "rowActions": rowActions })}
96
- {...definedOptions({ emptyState, expandedRowRender })}
97
- />
90
+ <div data-svadmin-list-page-table>
91
+ <AutoTable
92
+ {rendering}
93
+ {resourceName}
94
+ showHeader={false}
95
+ {...definedOptions({ "selectable": selectable })}
96
+ {...definedOptions({ allowSelectAllMatching })}
97
+ {...definedOptions({ savedViewProvider })}
98
+ {...definedOptions({ batchActions })}
99
+ {...definedOptions({ "defaultCellRenderer": cellRenderer })}
100
+ {...definedOptions({ "rowActions": rowActions })}
101
+ {...definedOptions({ emptyState, expandedRowRender })}
102
+ />
103
+ </div>
98
104
  </div>
@@ -2,6 +2,7 @@ import type { ResourceRendering } from '../rendering/index.js';
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { FieldDefinition } from '@svadmin/core';
4
4
  import type { BatchSelection } from './table-contract';
5
+ import type { SavedListViewProvider } from './saved-list-views.js';
5
6
  interface Props {
6
7
  resourceName: string;
7
8
  rendering?: ResourceRendering | undefined;
@@ -10,6 +11,7 @@ interface Props {
10
11
  /** Passthrough: enable row selection checkboxes */
11
12
  selectable?: boolean;
12
13
  allowSelectAllMatching?: boolean;
14
+ savedViewProvider?: SavedListViewProvider;
13
15
  /** Passthrough: custom header actions */
14
16
  headerActions?: Snippet;
15
17
  /** Optional status tabs slot above filter/table */
@@ -25,6 +25,10 @@
25
25
  defaultIdentifier = '',
26
26
  defaultPassword = '',
27
27
  loginHint,
28
+ identifierLabel,
29
+ identifierPlaceholder,
30
+ passwordLabel,
31
+ loginButtonLabel,
28
32
  } = $props<{
29
33
  title?: string;
30
34
  onSuccess?: () => void;
@@ -32,6 +36,10 @@
32
36
  defaultIdentifier?: string;
33
37
  defaultPassword?: string;
34
38
  loginHint?: string;
39
+ identifierLabel?: string;
40
+ identifierPlaceholder?: string;
41
+ passwordLabel?: string;
42
+ loginButtonLabel?: string;
35
43
  }>();
36
44
 
37
45
  const adminContext = captureAdminContext();
@@ -72,6 +80,7 @@
72
80
  {/if}
73
81
  {/snippet}
74
82
 
83
+ <div data-svadmin-login-page>
75
84
  <AuthPageShell brand={title} title={i18n.t('auth.welcomeBack')} description={i18n.t('auth.welcomeMessage')} {footer}>
76
85
  {#if loginHint || defaultIdentifier || defaultPassword}
77
86
  <aside data-svadmin-demo-credentials class="svadmin-u-fb88ccaacf5b svadmin-u-421ac2be5045 svadmin-u-ca6bcd4b6f3f svadmin-u-18049387f0af svadmin-u-60ed9c3ef326 svadmin-u-eb6e8b881acd">
@@ -95,9 +104,9 @@
95
104
  </aside>
96
105
  {/if}
97
106
 
98
- <form onsubmit={handleSubmit} class="svadmin-u-b43b4c086d9a">
107
+ <form onsubmit={handleSubmit} class="svadmin-u-b43b4c086d9a" data-svadmin-login-form>
99
108
  {#if error}
100
- <Alert.Root variant="destructive">
109
+ <Alert.Root variant="destructive" id="login-error" data-svadmin-login-error aria-live="assertive">
101
110
  <AlertCircle class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3" />
102
111
  <Alert.Description class="svadmin-u-fc7473ca09eb svadmin-u-2689f3958069">{error}</Alert.Description>
103
112
  </Alert.Root>
@@ -105,17 +114,20 @@
105
114
 
106
115
  <div class="svadmin-u-6f7e013d6499">
107
116
  <Label for="login-identifier">
108
- {i18n.t('auth.usernameOrEmail')}
117
+ {identifierLabel ?? i18n.t('auth.usernameOrEmail')}
109
118
  </Label>
110
- <div class="svadmin-u-d89972fe17d6">
111
- <User class="svadmin-u-a4326536b8f5 svadmin-u-da4dbfbc4fdc svadmin-u-22e59b722111 svadmin-u-d694ba66e322 svadmin-u-536a7530a44f svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-36b381be4df3 svadmin-u-bfa603190748" />
119
+ <div class="svadmin-auth-input-field" data-svadmin-login-identifier-field>
120
+ <User class="svadmin-auth-input-icon" data-svadmin-auth-input-icon aria-hidden="true" />
112
121
  <Input
113
122
  id="login-identifier"
123
+ data-svadmin-login-identifier
114
124
  type="text"
115
- placeholder={i18n.t('auth.identifierPlaceholder')}
125
+ placeholder={identifierPlaceholder ?? i18n.t('auth.identifierPlaceholder')}
116
126
  bind:value={identifier}
117
- class="svadmin-u-9e83b2412bc9"
127
+ class="svadmin-auth-input-control"
118
128
  autocomplete="username"
129
+ aria-invalid={error ? 'true' : undefined}
130
+ aria-describedby={error ? 'login-error' : undefined}
119
131
  />
120
132
  </div>
121
133
  </div>
@@ -123,7 +135,7 @@
123
135
  <div class="svadmin-u-6f7e013d6499">
124
136
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc">
125
137
  <Label for="login-password">
126
- {i18n.t('auth.password')}
138
+ {passwordLabel ?? i18n.t('auth.password')}
127
139
  </Label>
128
140
  {#if authProvider?.forgotPassword}
129
141
  <Button variant="link" class="svadmin-u-b8f0a08ece1e svadmin-u-8a539c7fe216 svadmin-u-359090c2d529 svadmin-u-2689f3958069" onclick={() => adminContext.navigate('/forgot-password')}>
@@ -133,17 +145,20 @@
133
145
  </div>
134
146
  <PasswordInput
135
147
  id="login-password"
136
- label=""
148
+ label={passwordLabel ?? i18n.t('auth.password')}
149
+ showLabel={false}
137
150
  bind:value={password}
138
151
  autocomplete="current-password"
152
+ ariaInvalid={error ? 'true' : undefined}
153
+ ariaDescribedby={error ? 'login-error' : undefined}
139
154
  />
140
155
  </div>
141
156
 
142
- <Button type="submit" class="svadmin-u-426b8b75185b svadmin-u-6da6a3c3f741" disabled={login.isLoading}>
157
+ <Button type="submit" data-svadmin-login-submit class="svadmin-u-426b8b75185b svadmin-u-6da6a3c3f741" disabled={login.isLoading} aria-busy={login.isLoading}>
143
158
  {#if login.isLoading}
144
159
  <Loader2 class="svadmin-u-cd0d9c512cdc svadmin-u-72470489ff4e svadmin-u-afbdd13a380e svadmin-u-d2347e8497a9" />
145
160
  {/if}
146
- {i18n.t('auth.loginButton')}
161
+ {loginButtonLabel ?? i18n.t('auth.loginButton')}
147
162
  </Button>
148
163
  </form>
149
164
 
@@ -171,3 +186,4 @@
171
186
  </div>
172
187
  {/if}
173
188
  </AuthPageShell>
189
+ </div>
@@ -10,6 +10,10 @@ type $$ComponentProps = {
10
10
  defaultIdentifier?: string;
11
11
  defaultPassword?: string;
12
12
  loginHint?: string;
13
+ identifierLabel?: string;
14
+ identifierPlaceholder?: string;
15
+ passwordLabel?: string;
16
+ loginButtonLabel?: string;
13
17
  };
14
18
  declare const LoginPage: import("svelte").Component<$$ComponentProps, {}, "">;
15
19
  type LoginPage = ReturnType<typeof LoginPage>;
@@ -33,12 +33,13 @@
33
33
  let loading = $state(false);
34
34
  let saving = $state(false);
35
35
  let loaded = $state(false);
36
- let error = $state('');
36
+ let loadError = $state('');
37
+ let saveError = $state('');
37
38
  let epoch = 0;
38
39
  async function load() {
39
40
  const provider = preferencesProvider;
40
41
  const request = ++epoch;
41
- error = ''; loaded = false; saving = false;
42
+ loadError = ''; saveError = ''; loaded = false; saving = false;
42
43
  loading = Boolean(provider);
43
44
  if (!provider) return;
44
45
  try {
@@ -47,7 +48,7 @@
47
48
  emailAlerts = { ...result.email }; pushAlerts = { ...result.push }; smsAlerts = { ...result.sms };
48
49
  loaded = true;
49
50
  } catch (caught) {
50
- if (request === epoch) error = caught instanceof Error ? caught.message : String(caught);
51
+ if (request === epoch) loadError = caught instanceof Error ? caught.message : String(caught);
51
52
  } finally {
52
53
  if (request === epoch) loading = false;
53
54
  }
@@ -61,12 +62,12 @@
61
62
  const provider = preferencesProvider;
62
63
  if (!provider || !loaded || saving) return;
63
64
  const request = epoch;
64
- saving = true; error = '';
65
+ saving = true; saveError = '';
65
66
  try {
66
67
  await provider.save({ email: { ...emailAlerts }, push: { ...pushAlerts }, sms: { ...smsAlerts } });
67
68
  if (request === epoch) notification.success(i18n.t('common.updateSuccess'), 3000);
68
69
  } catch (caught) {
69
- if (request === epoch) error = caught instanceof Error ? caught.message : String(caught);
70
+ if (request === epoch) saveError = caught instanceof Error ? caught.message : String(caught);
70
71
  } finally {
71
72
  if (request === epoch) saving = false;
72
73
  }
@@ -85,9 +86,15 @@
85
86
  {#if !preferencesProvider}
86
87
  <FeedbackNotice tone="warning" message={i18n.locale === 'zh-CN' ? '未配置通知偏好存储服务,设置不可保存。' : 'Notification preference storage is not configured. Settings cannot be saved.'} />
87
88
  {/if}
88
- {#if loading}<DataState state="loading" />{/if}
89
- {#if error}<p role="alert">{error}</p>{#if !loaded}<Button onclick={load}>{i18n.t('common.retry')}</Button>{/if}{/if}
90
- <fieldset disabled={!loaded || loading || saving} class="svadmin-u-b3542e058833">
89
+ {#if loading}
90
+ <DataState state="loading" />
91
+ {:else if loadError}
92
+ <DataState state="error" description={loadError} retry={load} />
93
+ {:else if saveError}
94
+ <FeedbackNotice tone="danger" message={saveError} />
95
+ {/if}
96
+ {#if loaded || !preferencesProvider}
97
+ <fieldset disabled={!preferencesProvider || saving} class="svadmin-u-b3542e058833">
91
98
  <SettingsGroup title={i18n.t('notifications.emailTitle')} description={i18n.t('notifications.emailDescription')} headingLevel={headingLevel === 'h1' ? 'h2' : 'h3'}>
92
99
  <div class="svadmin-u-fa6acbf81d74 svadmin-u-e783642739e3">
93
100
  <SettingsFieldRow controlId={`${controlId}-email-security`} label={i18n.t('notifications.securityAlerts')} description={i18n.t('notifications.securityAlertsDesc')}>{#snippet control()}<Switch id={`${controlId}-email-security`} bind:checked={emailAlerts.security} />{/snippet}</SettingsFieldRow>
@@ -106,4 +113,5 @@
106
113
  <SettingsFieldRow controlId={`${controlId}-sms-security`} label={i18n.t('notifications.criticalSecurity')} description={i18n.t('notifications.criticalSecurityDesc')}>{#snippet control()}<Switch id={`${controlId}-sms-security`} bind:checked={smsAlerts.security} />{/snippet}</SettingsFieldRow>
107
114
  </SettingsGroup>
108
115
  </fieldset>
116
+ {/if}
109
117
  </div>
@@ -6,7 +6,7 @@
6
6
  import DataState from './content/DataState.svelte';
7
7
 
8
8
  export type PagePatternKind = 'list' | 'form' | 'detail' | 'workspace' | 'dashboard';
9
- export type PagePatternState = 'ready' | 'loading' | 'empty' | 'error' | 'forbidden';
9
+ export type PagePatternState = 'ready' | 'loading' | 'empty' | 'filtered-empty' | 'error' | 'forbidden';
10
10
 
11
11
  interface Props {
12
12
  kind: PagePatternKind;
@@ -68,7 +68,7 @@
68
68
  >
69
69
  <div data-page-pattern-kind={kind} data-page-pattern-state={state}>
70
70
  {#if showState}
71
- {#if state === 'loading' || state === 'empty' || state === 'error' || state === 'forbidden'}
71
+ {#if state === 'loading' || state === 'empty' || state === 'filtered-empty' || state === 'error' || state === 'forbidden'}
72
72
  <DataState
73
73
  {state}
74
74
  {...definedOptions({ title: stateTitle })}
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  export type PagePatternKind = 'list' | 'form' | 'detail' | 'workspace' | 'dashboard';
3
- export type PagePatternState = 'ready' | 'loading' | 'empty' | 'error' | 'forbidden';
3
+ export type PagePatternState = 'ready' | 'loading' | 'empty' | 'filtered-empty' | 'error' | 'forbidden';
4
4
  interface Props {
5
5
  kind: PagePatternKind;
6
6
  title: string;
@@ -16,7 +16,10 @@
16
16
  autocomplete?: FullAutoFill;
17
17
  showStrength?: boolean;
18
18
  disabled?: boolean;
19
+ showLabel?: boolean;
19
20
  class?: string;
21
+ ariaInvalid?: 'true' | 'false' | undefined;
22
+ ariaDescribedby?: string | undefined;
20
23
  }
21
24
 
22
25
  let {
@@ -27,7 +30,10 @@
27
30
  autocomplete = 'current-password',
28
31
  showStrength = false,
29
32
  disabled = false,
33
+ showLabel = true,
30
34
  class: className = '',
35
+ ariaInvalid,
36
+ ariaDescribedby,
31
37
  }: Props = $props();
32
38
 
33
39
  let showPassword = $state(false);
@@ -44,10 +50,10 @@
44
50
  return Math.min(score, 100);
45
51
  });
46
52
 
47
- const strengthColor = $derived(
48
- strength < 30 ? 'bg-destructive' :
49
- strength < 60 ? 'bg-warning' :
50
- strength < 80 ? 'bg-info' : 'bg-success'
53
+ const strengthTone = $derived(
54
+ strength < 30 ? 'danger' :
55
+ strength < 60 ? 'warning' :
56
+ strength < 80 ? 'info' : 'success'
51
57
  );
52
58
 
53
59
  const strengthLabel = $derived(
@@ -57,45 +63,58 @@
57
63
  );
58
64
  </script>
59
65
 
60
- <div class="svadmin-u-6f7e013d6499 {className}">
61
- <Label for={id}>{label}</Label>
62
- <div class="svadmin-u-d89972fe17d6">
63
- <Lock class="svadmin-u-da4dbfbc4fdc svadmin-u-22e59b722111 svadmin-u-d694ba66e322 svadmin-u-36b381be4df3 svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-bfa603190748 svadmin-u-a4326536b8f5 svadmin-u-536a7530a44f" />
66
+ <div
67
+ class="svadmin-password-input {className}"
68
+ data-svadmin-password-input
69
+ data-svadmin-login-password
70
+ data-password-visible={showPassword ? 'true' : 'false'}
71
+ >
72
+ {#if showLabel}
73
+ <Label for={id} class="svadmin-password-input__label" data-svadmin-password-label>{label}</Label>
74
+ {/if}
75
+ <div class="svadmin-password-input__field" data-svadmin-password-field>
76
+ <Lock class="svadmin-password-input__icon" data-svadmin-password-icon aria-hidden="true" />
64
77
  <Input
65
78
  {id}
66
79
  type={showPassword ? 'text' : 'password'}
67
80
  {placeholder}
68
81
  bind:value
69
- class="svadmin-u-9e83b2412bc9 svadmin-u-1b1df78e4d0a"
82
+ class="svadmin-password-input__control"
83
+ data-svadmin-password-control
70
84
  {autocomplete}
71
85
  {disabled}
86
+ aria-invalid={ariaInvalid}
87
+ aria-describedby={ariaDescribedby}
72
88
  />
73
89
  <TooltipButton
74
90
  tooltip={showPassword ? i18n.t('common.hidePassword') : i18n.t('common.showPassword')}
75
91
  variant="ghost"
76
92
  size="icon"
77
93
  type="button"
78
- class="svadmin-u-da4dbfbc4fdc svadmin-u-68d3fc190004 svadmin-u-d694ba66e322 svadmin-u-36b381be4df3 svadmin-u-d0a52b312f7d svadmin-u-cbbf90f9a828 svadmin-u-536a7530a44f"
94
+ class="svadmin-password-input__toggle"
95
+ data-svadmin-password-toggle
96
+ aria-controls={id}
97
+ aria-pressed={showPassword}
79
98
  onclick={(e) => { e.preventDefault(); showPassword = !showPassword; }}
80
99
  {disabled}
81
- tabindex={-1}
82
100
  >
83
101
  {#if showPassword}
84
- <EyeOff class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3" />
102
+ <EyeOff class="svadmin-password-input__glyph" />
85
103
  {:else}
86
- <Eye class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3" />
104
+ <Eye class="svadmin-password-input__glyph" />
87
105
  {/if}
88
106
  </TooltipButton>
89
107
  </div>
90
108
  {#if showStrength && value}
91
- <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4">
92
- <div class="svadmin-u-36e579c0b41c svadmin-u-095acb275581 svadmin-u-ac204c108886 svadmin-u-2ef11f1cb219 svadmin-u-2cd02d11d1af">
109
+ <div class="svadmin-password-input__strength" data-svadmin-password-strength>
110
+ <div class="svadmin-password-input__strength-track">
93
111
  <div
94
- class="svadmin-u-668b21aa5409 svadmin-u-ac204c108886 svadmin-u-0fe7d7d814d0 svadmin-u-7890552ecd63 {strengthColor}"
112
+ class="svadmin-password-input__strength-fill"
113
+ data-tone={strengthTone}
95
114
  style="width: {strength}%"
96
115
  ></div>
97
116
  </div>
98
- <span class="svadmin-u-359090c2d529 svadmin-u-bfa603190748 svadmin-u-e7e371071bc5 svadmin-u-308fc069e46e">{strengthLabel}</span>
117
+ <span class="svadmin-password-input__strength-label">{strengthLabel}</span>
99
118
  </div>
100
119
  {/if}
101
120
  </div>
@@ -7,7 +7,10 @@ interface Props {
7
7
  autocomplete?: FullAutoFill;
8
8
  showStrength?: boolean;
9
9
  disabled?: boolean;
10
+ showLabel?: boolean;
10
11
  class?: string;
12
+ ariaInvalid?: 'true' | 'false' | undefined;
13
+ ariaDescribedby?: string | undefined;
11
14
  }
12
15
  declare const PasswordInput: import("svelte").Component<Props, {}, "value">;
13
16
  type PasswordInput = ReturnType<typeof PasswordInput>;
@@ -13,6 +13,7 @@
13
13
  import CloneButton from './buttons/CloneButton.svelte';
14
14
  import { Button } from './ui/button/index.js';
15
15
  import { RefreshCw } from '@lucide/svelte';
16
+ import DataState from './content/DataState.svelte';
16
17
 
17
18
  const i18n = useTranslation();
18
19
 
@@ -73,12 +74,13 @@
73
74
  });
74
75
  </script>
75
76
 
76
- <div data-svadmin-rendering-resource={activeRendering?.resource.name} data-svadmin-rendering-kind={activeRendering ? 'show' : undefined} class="svadmin-u-3e7ce58d64fa {className}">
77
- <PageHeader
78
- title="{resource.label} {i18n.t('common.detail')} #{id}"
79
- onBack={goBack}
80
- backLabel={i18n.t('common.back')}
81
- >
77
+ <div data-svadmin-rendering-resource={activeRendering?.resource.name} data-svadmin-rendering-kind={activeRendering ? 'show' : undefined} data-svadmin-crud-page="show" data-svadmin-resource={resourceName} data-svadmin-record-id={id} class="svadmin-u-3e7ce58d64fa {className}">
78
+ <div data-svadmin-crud-page-header>
79
+ <PageHeader
80
+ title="{resource.label} {i18n.t('common.detail')} #{id}"
81
+ onBack={goBack}
82
+ backLabel={i18n.t('common.back')}
83
+ >
82
84
  {#snippet actions()}
83
85
  {#if detail.canRead && query.isSuccess && resource.canEdit !== false}
84
86
  <EditButton resource={resourceName} recordItemId={id} hideText />
@@ -98,8 +100,10 @@
98
100
  {@render headerActions()}
99
101
  {/if}
100
102
  {/snippet}
101
- </PageHeader>
103
+ </PageHeader>
104
+ </div>
102
105
 
106
+ <div data-svadmin-crud-page-content data-svadmin-detail-state={detail.checkingPermission || query.isLoading ? 'loading' : !detail.canRead ? 'forbidden' : query.isError ? 'error' : query.isSuccess ? 'ready' : 'empty'}>
103
107
  {#if detail.checkingPermission || query.isLoading}
104
108
  <Card.Root class="svadmin-u-2cd02d11d1af svadmin-u-6ee2d41e2d2d svadmin-u-438b2237b8d6">
105
109
  <Card.Content class="svadmin-u-8a539c7fe216">
@@ -112,15 +116,11 @@
112
116
  </Card.Content>
113
117
  </Card.Root>
114
118
  {:else if !detail.canRead}
115
- <p role="status" data-svadmin-access-denied>{i18n.t('common.accessDenied')}</p>
116
- {:else if query.isError}
117
- <div role="alert">
118
- <p>{i18n.t('common.operationFailed')}</p>
119
- <Button type="button" variant="ghost" size="icon" title={i18n.t('common.retry')}
120
- aria-label={i18n.t('common.retry')} disabled={query.isFetching} onclick={detail.refresh}>
121
- <RefreshCw class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3" />
122
- </Button>
119
+ <div data-svadmin-access-denied>
120
+ <DataState state="forbidden" title={i18n.t('common.accessDenied')} />
123
121
  </div>
122
+ {:else if query.isError}
123
+ <DataState state="error" title={i18n.t('common.operationFailed')} retry={detail.refresh} />
124
124
  {:else if query.isSuccess}
125
125
  {@const record = activeRendering ? activeRendering.record(query.data.data) : query.data.data}
126
126
  {#if layout === 'grid'}
@@ -165,13 +165,12 @@
165
165
  </Card.Root>
166
166
  {/if}
167
167
  {#if children}
168
- {@render children()}
168
+ <div data-svadmin-crud-page-extension>
169
+ {@render children()}
170
+ </div>
169
171
  {/if}
170
172
  {:else}
171
- <Card.Root class="svadmin-u-2cd02d11d1af svadmin-u-6ee2d41e2d2d svadmin-u-438b2237b8d6">
172
- <Card.Content class="svadmin-u-845f53365c8d svadmin-u-ca6bf63030aa">
173
- <p class="svadmin-u-bfa603190748">{i18n.t('common.noData')}</p>
174
- </Card.Content>
175
- </Card.Root>
173
+ <DataState state="empty" description={i18n.t('common.noData')} />
176
174
  {/if}
175
+ </div>
177
176
  </div>
@@ -20,7 +20,7 @@
20
20
  import { Input } from '../ui/input/index.js';
21
21
  import DataState from '../content/DataState.svelte';
22
22
  import FeedbackNotice from '../content/FeedbackNotice.svelte';
23
- import { Copy, Send } from '@lucide/svelte';
23
+ import { Copy, Send, X } from '@lucide/svelte';
24
24
 
25
25
  let { provider }: { provider?: MemberDirectoryProvider | undefined } = $props();
26
26
  const context = captureAdminContext();
@@ -29,6 +29,7 @@
29
29
  const controlId = $props.id();
30
30
  let rows = $state<DirectoryMember[]>([]);
31
31
  let query = $state('');
32
+ let searchInput = $state<HTMLInputElement | null>(null);
32
33
  let email = $state('');
33
34
  let loading = $state(false);
34
35
  let sending = $state(false);
@@ -37,6 +38,10 @@
37
38
  let status = $state('');
38
39
  let epoch = 0;
39
40
  const filtered = $derived(rows.filter(row => `${row.name} ${row.email}`.toLowerCase().includes(query.toLowerCase())));
41
+ function clearSearch(): void {
42
+ query = '';
43
+ searchInput?.focus();
44
+ }
40
45
  async function load() {
41
46
  const current = provider;
42
47
  const request = ++epoch;
@@ -117,10 +122,18 @@
117
122
  {:else}
118
123
  <div class="w-full max-w-sm space-y-2">
119
124
  <label for={`${controlId}-search`} class="block text-sm font-medium text-foreground">{isZh ? '搜索成员' : 'Search members'}</label>
120
- <Input id={`${controlId}-search`} type="search" bind:value={query} />
125
+ <Input id={`${controlId}-search`} type="search" bind:value={query} bind:ref={searchInput} />
121
126
  </div>
122
- {#if filtered.length === 0}
123
- <DataState state="empty" title={isZh ? '没有匹配的成员' : 'No matching members'} />
127
+ {#if rows.length === 0}
128
+ <DataState state="empty" title={isZh ? '暂无成员' : 'No members yet'} />
129
+ {:else if filtered.length === 0}
130
+ <DataState state="filtered-empty" title={isZh ? '没有匹配的成员' : 'No matching members'} description={isZh ? '请清除或调整搜索条件。' : 'Clear or adjust the search.'}>
131
+ {#snippet action()}
132
+ <Button type="button" variant="outline" size="sm" onclick={clearSearch}>
133
+ <X size={16} aria-hidden="true" />{isZh ? '清除搜索' : 'Clear search'}
134
+ </Button>
135
+ {/snippet}
136
+ </DataState>
124
137
  {:else}
125
138
  <!-- svelte-ignore a11y_no_noninteractive_tabindex (横向滚动区域需支持键盘聚焦与滚动) -->
126
139
  <div class="member-directory-scroll w-full min-w-0 overflow-x-auto" role="region" aria-label={isZh ? '成员目录' : 'Member directory'} tabindex="0">
@@ -23,5 +23,12 @@
23
23
  <ContentPageHeader title={i18n.t('account.securityLog')} description={i18n.t('account.securityLogDescription')} {...definedOptions({ "actions": undefined })} />
24
24
  <div class="svadmin-u-60fbb7713999 svadmin-u-77c08e015d14"><Button variant="outline" size="sm" onclick={() => downloadRows('sample-security-events.json', filtered)}><Download class="svadmin-u-783b0d9d1e2c" />{i18n.t('common.export')}</Button></div>
25
25
  <FilterToolbar bind:query placeholder={i18n.t('common.search')} />
26
- <SecurityEventTable events={filtered} />
26
+ <SecurityEventTable
27
+ events={filtered}
28
+ {...definedOptions({
29
+ state: query.trim() && filtered.length === 0 ? 'filtered-empty' : undefined,
30
+ stateTitle: query.trim() && filtered.length === 0 ? (i18n.locale === 'zh-CN' ? '没有匹配的安全事件' : 'No matching security events') : undefined,
31
+ stateDescription: query.trim() && filtered.length === 0 ? (i18n.locale === 'zh-CN' ? '请清除或调整搜索条件。' : 'Clear or adjust the search.') : undefined,
32
+ })}
33
+ />
27
34
  </ContentPageShell>
@@ -14,14 +14,24 @@
14
14
  </script>
15
15
 
16
16
  <script lang="ts">
17
+ import type { Snippet } from 'svelte';
18
+ import { definedOptions } from '@svadmin/core/options';
17
19
  import { cn } from '../../utils.js';
18
20
  import StatusBadge from './StatusBadge.svelte';
19
21
  import DataState from './DataState.svelte';
22
+ import type { DataStateKind } from './DataState.svelte';
20
23
 
21
24
  interface Props {
22
25
  items?: TimelineItem[];
23
26
  emptyTitle?: string;
24
27
  emptyDescription?: string;
28
+ state?: DataStateKind;
29
+ stateTitle?: string;
30
+ stateDescription?: string;
31
+ retry?: () => void;
32
+ retryLabel?: string;
33
+ loadingLabel?: string;
34
+ action?: Snippet;
25
35
  class?: string;
26
36
  }
27
37
 
@@ -29,6 +39,13 @@
29
39
  items = [],
30
40
  emptyTitle = 'No timeline events',
31
41
  emptyDescription = 'Activity events and status transitions will appear here.',
42
+ state,
43
+ stateTitle,
44
+ stateDescription,
45
+ retry,
46
+ retryLabel,
47
+ loadingLabel,
48
+ action,
32
49
  class: className = '',
33
50
  }: Props = $props();
34
51
 
@@ -39,13 +56,24 @@
39
56
  info: 'svadmin-u-75b1bec3ea0e svadmin-u-05f954a846d6 svadmin-u-20aaf08a7ed1',
40
57
  neutral: 'svadmin-u-43ed7af3fc8a svadmin-u-18049387f0af svadmin-u-bfa603190748',
41
58
  };
59
+ const resolvedState = $derived(state ?? (items.length === 0 ? 'empty' : undefined));
42
60
  </script>
43
61
 
44
- <div class={cn('svadmin-u-3e7ce58d64fa', className)}>
45
- {#if items.length === 0}
46
- <DataState state="empty" title={emptyTitle} description={emptyDescription} />
62
+ <div class={cn('svadmin-u-3e7ce58d64fa', className)} data-svadmin-audit-timeline data-svadmin-audit-state={resolvedState ?? 'ready'}>
63
+ {#if resolvedState}
64
+ <DataState
65
+ state={resolvedState}
66
+ {...definedOptions({
67
+ title: stateTitle ?? (resolvedState === 'empty' ? emptyTitle : undefined),
68
+ description: stateDescription ?? (resolvedState === 'empty' ? emptyDescription : undefined),
69
+ retry,
70
+ retryLabel,
71
+ loadingLabel,
72
+ action,
73
+ })}
74
+ />
47
75
  {:else}
48
- <div class="svadmin-u-d89972fe17d6 svadmin-u-9079b62ef1cb svadmin-u-b3542e058833 svadmin-u-884dada40b17 svadmin-u-699f6fc4b3e0 svadmin-u-37f34e63818d svadmin-u-f76c7493f919 svadmin-u-8f848a57f066 svadmin-u-3242dfaf010c">
76
+ <div class="svadmin-u-d89972fe17d6 svadmin-u-9079b62ef1cb svadmin-u-b3542e058833 svadmin-u-884dada40b17 svadmin-u-699f6fc4b3e0 svadmin-u-37f34e63818d svadmin-u-f76c7493f919 svadmin-u-8f848a57f066 svadmin-u-3242dfaf010c" data-svadmin-audit-events>
49
77
  {#each items as item (item.id)}
50
78
  {@const statusTone = item.status ?? 'info'}
51
79
  <div class="svadmin-u-d89972fe17d6 svadmin-u-60fbb7713999 svadmin-u-8dddea0773ed svadmin-u-44ee8ba0a421 svadmin-u-fc7473ca09eb group">