@svadmin/ui 0.3.0 → 0.4.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 (108) hide show
  1. package/package.json +7 -3
  2. package/src/actions.ts +110 -0
  3. package/src/components/AdminApp.svelte +7 -4
  4. package/src/components/Authenticated.svelte +1 -7
  5. package/src/components/AutoForm.svelte +158 -69
  6. package/src/components/AutoSaveIndicator.svelte +19 -14
  7. package/src/components/AutoTable.svelte +465 -243
  8. package/src/components/Breadcrumbs.svelte +20 -17
  9. package/src/components/ComboboxField.svelte +122 -0
  10. package/src/components/CommandPalette.svelte +93 -0
  11. package/src/components/ConfigErrorScreen.svelte +53 -151
  12. package/src/components/ConfirmDialog.svelte +25 -17
  13. package/src/components/CreatePage.svelte +3 -2
  14. package/src/components/DevTools.svelte +99 -230
  15. package/src/components/DrawerForm.svelte +19 -10
  16. package/src/components/EditPage.svelte +25 -6
  17. package/src/components/EmptyState.svelte +5 -1
  18. package/src/components/ErrorBoundary.svelte +38 -40
  19. package/src/components/ErrorComponent.svelte +18 -17
  20. package/src/components/FieldRenderer.svelte +240 -75
  21. package/src/components/ForgotPasswordPage.svelte +20 -111
  22. package/src/components/Header.svelte +28 -8
  23. package/src/components/InferencerPanel.svelte +136 -110
  24. package/src/components/InfiniteList.svelte +93 -0
  25. package/src/components/Layout.svelte +85 -22
  26. package/src/components/ListPage.svelte +25 -1
  27. package/src/components/LiveIndicator.svelte +30 -87
  28. package/src/components/LoginPage.svelte +33 -154
  29. package/src/components/ModalForm.svelte +6 -2
  30. package/src/components/PageSkeleton.svelte +2 -1
  31. package/src/components/PasswordInput.svelte +95 -0
  32. package/src/components/RegisterPage.svelte +35 -164
  33. package/src/components/ShowPage.svelte +32 -33
  34. package/src/components/Sidebar.svelte +185 -73
  35. package/src/components/StatsCard.svelte +12 -3
  36. package/src/components/StepsForm.svelte +139 -0
  37. package/src/components/Toast.svelte +15 -54
  38. package/src/components/TooltipButton.svelte +52 -0
  39. package/src/components/UndoableNotification.svelte +28 -96
  40. package/src/components/UpdatePasswordPage.svelte +32 -160
  41. package/src/components/buttons/RefreshButton.svelte +7 -2
  42. package/src/components/fieldComponentMap.ts +79 -0
  43. package/src/components/fields/BooleanField.svelte +9 -9
  44. package/src/components/fields/EmailField.svelte +2 -8
  45. package/src/components/fields/FileField.svelte +4 -9
  46. package/src/components/fields/ImageField.svelte +2 -7
  47. package/src/components/fields/JsonField.svelte +72 -0
  48. package/src/components/fields/MarkdownField.svelte +2 -12
  49. package/src/components/fields/MultiSelectField.svelte +20 -0
  50. package/src/components/fields/RelationField.svelte +24 -0
  51. package/src/components/fields/RichTextField.svelte +35 -0
  52. package/src/components/fields/SelectField.svelte +14 -0
  53. package/src/components/fields/TagField.svelte +6 -11
  54. package/src/components/fields/TextField.svelte +2 -7
  55. package/src/components/fields/UrlField.svelte +4 -10
  56. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +25 -0
  57. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +16 -0
  58. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +8 -0
  59. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +8 -0
  60. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +16 -0
  61. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +6 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +16 -0
  63. package/src/components/ui/alert-dialog/index.ts +20 -0
  64. package/src/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +18 -0
  65. package/src/components/ui/breadcrumb/breadcrumb-item.svelte +15 -0
  66. package/src/components/ui/breadcrumb/breadcrumb-link.svelte +15 -0
  67. package/src/components/ui/breadcrumb/breadcrumb-list.svelte +15 -0
  68. package/src/components/ui/breadcrumb/breadcrumb-page.svelte +18 -0
  69. package/src/components/ui/breadcrumb/breadcrumb-separator.svelte +22 -0
  70. package/src/components/ui/breadcrumb/breadcrumb.svelte +11 -0
  71. package/src/components/ui/breadcrumb/index.ts +7 -0
  72. package/src/components/ui/collapsible/index.ts +7 -0
  73. package/src/components/ui/command/command-dialog.svelte +31 -0
  74. package/src/components/ui/command/index.ts +2 -0
  75. package/src/components/ui/context-menu/index.ts +11 -0
  76. package/src/components/ui/drawer/drawer-content.svelte +21 -0
  77. package/src/components/ui/drawer/drawer-description.svelte +11 -0
  78. package/src/components/ui/drawer/drawer-footer.svelte +11 -0
  79. package/src/components/ui/drawer/drawer-header.svelte +11 -0
  80. package/src/components/ui/drawer/drawer-title.svelte +11 -0
  81. package/src/components/ui/drawer/drawer.svelte +18 -0
  82. package/src/components/ui/drawer/index.ts +6 -0
  83. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +43 -0
  84. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +27 -0
  85. package/src/components/ui/dropdown-menu/index.ts +6 -0
  86. package/src/components/ui/label/index.ts +1 -0
  87. package/src/components/ui/label/label.svelte +18 -0
  88. package/src/components/ui/pagination/index.ts +7 -0
  89. package/src/components/ui/pagination/pagination-content.svelte +11 -0
  90. package/src/components/ui/pagination/pagination-ellipsis.svelte +12 -0
  91. package/src/components/ui/pagination/pagination-item.svelte +11 -0
  92. package/src/components/ui/pagination/pagination-link.svelte +26 -0
  93. package/src/components/ui/pagination/pagination-next.svelte +22 -0
  94. package/src/components/ui/pagination/pagination-previous.svelte +22 -0
  95. package/src/components/ui/pagination/pagination.svelte +11 -0
  96. package/src/components/ui/popover/index.ts +12 -0
  97. package/src/components/ui/popover/popover-content.svelte +25 -0
  98. package/src/components/ui/progress/index.ts +1 -0
  99. package/src/components/ui/progress/progress.svelte +22 -0
  100. package/src/components/ui/scroll-area/index.ts +1 -0
  101. package/src/components/ui/scroll-area/scroll-area.svelte +20 -0
  102. package/src/components/ui/sheet/index.ts +15 -1
  103. package/src/components/ui/sheet/sheet-content.svelte +28 -0
  104. package/src/components/ui/sheet/sheet-description.svelte +22 -0
  105. package/src/components/ui/sheet/sheet-footer.svelte +22 -0
  106. package/src/components/ui/sheet/sheet-header.svelte +22 -0
  107. package/src/components/ui/sheet/sheet-title.svelte +22 -0
  108. package/src/index.ts +30 -1
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
+ import { fade } from 'svelte/transition';
2
3
  import { Wifi, WifiOff, Loader2 } from 'lucide-svelte';
4
+ import { Badge } from './ui/badge/index.js';
3
5
 
4
6
  let {
5
7
  status = 'disconnected',
@@ -14,12 +16,12 @@
14
16
  }>();
15
17
 
16
18
  const statusConfigs = {
17
- connected: { color: 'var(--live-connected, #22c55e)', label: 'Live', icon: Wifi },
18
- connecting: { color: 'var(--live-connecting, #f59e0b)', label: 'Connecting...', icon: Loader2 as typeof Wifi },
19
- disconnected: { color: 'var(--live-disconnected, #ef4444)', label: 'Offline', icon: WifiOff },
19
+ connected: { variant: 'default' as const, dotClass: 'bg-emerald-500', label: 'Live', icon: Wifi },
20
+ connecting: { variant: 'secondary' as const, dotClass: 'bg-amber-500', label: 'Connecting...', icon: Loader2 as typeof Wifi },
21
+ disconnected: { variant: 'destructive' as const, dotClass: 'bg-red-500', label: 'Offline', icon: WifiOff },
20
22
  } as const;
21
23
  type StatusKey = 'connected' | 'connecting' | 'disconnected';
22
- const statusConfig = $derived(statusConfigs[status as StatusKey]);
24
+ const cfg = $derived(statusConfigs[status as StatusKey]);
23
25
 
24
26
  const timeSinceEvent = $derived(
25
27
  lastEvent?.timestamp
@@ -28,88 +30,29 @@
28
30
  );
29
31
  </script>
30
32
 
31
- <div class="live-indicator" title={statusConfig.label}>
32
- <span class="live-dot" style="--dot-color: {statusConfig.color}">
33
- {#if status === 'connected'}
34
- <span class="live-pulse"></span>
35
- {/if}
36
- </span>
37
- <span class="live-label" style="color: {statusConfig.color}">
38
- {statusConfig.label}
39
- </span>
40
-
41
- {#if showDetails && resource}
42
- <span class="live-resource">{resource}</span>
43
- {/if}
44
-
45
- {#if showDetails && lastEvent}
46
- <span class="live-event">
47
- {lastEvent.type}
48
- {#if timeSinceEvent !== null && timeSinceEvent < 60}
49
- <span class="live-time">{timeSinceEvent}s ago</span>
33
+ {#key status}
34
+ <div transition:fade={{ duration: 200 }}>
35
+ <Badge variant={cfg.variant} class="gap-1.5 select-none" title={cfg.label}>
36
+ <span class="relative flex h-2 w-2">
37
+ <span class="{cfg.dotClass} rounded-full h-2 w-2"></span>
38
+ {#if status === 'connected'}
39
+ <span class="absolute inset-0 rounded-full {cfg.dotClass} opacity-40 animate-ping"></span>
40
+ {/if}
41
+ </span>
42
+ <span class="text-[0.6875rem] font-semibold uppercase tracking-wide">{cfg.label}</span>
43
+
44
+ {#if showDetails && resource}
45
+ <span class="text-[0.625rem] font-mono opacity-70">{resource}</span>
50
46
  {/if}
51
- </span>
52
- {/if}
53
- </div>
54
-
55
- <style>
56
- .live-indicator {
57
- display: inline-flex;
58
- align-items: center;
59
- gap: 0.375rem;
60
- font-size: 0.75rem;
61
- font-weight: 500;
62
- padding: 0.25rem 0.5rem;
63
- border-radius: 9999px;
64
- background: hsl(var(--muted) / 0.3);
65
- user-select: none;
66
- }
67
-
68
- .live-dot {
69
- position: relative;
70
- width: 8px;
71
- height: 8px;
72
- border-radius: 50%;
73
- background: var(--dot-color);
74
- flex-shrink: 0;
75
- }
76
-
77
- .live-pulse {
78
- position: absolute;
79
- inset: -3px;
80
- border-radius: 50%;
81
- background: var(--dot-color);
82
- opacity: 0.4;
83
- animation: live-pulse 2s ease-in-out infinite;
84
- }
85
47
 
86
- @keyframes live-pulse {
87
- 0%, 100% { transform: scale(1); opacity: 0.4; }
88
- 50% { transform: scale(1.8); opacity: 0; }
89
- }
90
-
91
- .live-label {
92
- font-size: 0.6875rem;
93
- font-weight: 600;
94
- text-transform: uppercase;
95
- letter-spacing: 0.05em;
96
- }
97
-
98
- .live-resource {
99
- font-size: 0.625rem;
100
- color: hsl(var(--muted-foreground));
101
- font-family: monospace;
102
- }
103
-
104
- .live-event {
105
- font-size: 0.625rem;
106
- color: hsl(var(--muted-foreground));
107
- display: flex;
108
- align-items: center;
109
- gap: 0.25rem;
110
- }
111
-
112
- .live-time {
113
- opacity: 0.6;
114
- }
115
- </style>
48
+ {#if showDetails && lastEvent}
49
+ <span class="text-[0.625rem] opacity-70">
50
+ {lastEvent.type}
51
+ {#if timeSinceEvent !== null && timeSinceEvent < 60}
52
+ <span class="opacity-60">{timeSinceEvent}s ago</span>
53
+ {/if}
54
+ </span>
55
+ {/if}
56
+ </Badge>
57
+ </div>
58
+ {/key}
@@ -4,8 +4,11 @@
4
4
  import { navigate } from '@svadmin/core/router';
5
5
  import { Button } from './ui/button/index.js';
6
6
  import { Input } from './ui/input/index.js';
7
+ import { Label } from './ui/label/index.js';
7
8
  import * as Card from './ui/card/index.js';
8
- import { LogIn, Mail, Lock, Eye, EyeOff } from 'lucide-svelte';
9
+ import * as Alert from './ui/alert/index.js';
10
+ import PasswordInput from './PasswordInput.svelte';
11
+ import { LogIn, Mail, Loader2, AlertCircle } from 'lucide-svelte';
9
12
 
10
13
  let { title = 'Admin', onSuccess } = $props<{
11
14
  title?: string;
@@ -17,7 +20,6 @@
17
20
 
18
21
  let email = $state('');
19
22
  let password = $state('');
20
- let showPassword = $state(false);
21
23
  let error = $state('');
22
24
 
23
25
  async function handleSubmit(e: SubmitEvent) {
@@ -36,11 +38,11 @@
36
38
  }
37
39
  </script>
38
40
 
39
- <div class="login-page">
40
- <div class="login-container">
41
- <Card.Card class="login-card">
42
- <Card.CardHeader class="login-header">
43
- <div class="login-icon">
41
+ <div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/[0.08] via-background to-primary/[0.04] p-4">
42
+ <div class="w-full max-w-[420px]">
43
+ <Card.Card class="backdrop-blur-xl border-border/50 shadow-[0_8px_32px_hsl(var(--primary)/0.08),0_2px_8px_hsl(0_0%_0%/0.06)]">
44
+ <Card.CardHeader class="text-center pb-2">
45
+ <div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-primary/10 text-primary mx-auto mb-3">
44
46
  <LogIn class="h-6 w-6" />
45
47
  </div>
46
48
  <Card.CardTitle class="text-2xl font-bold">{t('auth.welcomeBack')}</Card.CardTitle>
@@ -49,15 +51,16 @@
49
51
  <Card.CardContent>
50
52
  <form onsubmit={handleSubmit} class="space-y-4">
51
53
  {#if error}
52
- <div class="error-alert">
53
- <p>{error}</p>
54
- </div>
54
+ <Alert.Root variant="destructive">
55
+ <AlertCircle class="h-4 w-4" />
56
+ <Alert.Description>{error}</Alert.Description>
57
+ </Alert.Root>
55
58
  {/if}
56
59
 
57
60
  <div class="space-y-2">
58
- <label for="login-email" class="text-sm font-medium text-foreground">{t('auth.email')}</label>
59
- <div class="input-with-icon">
60
- <Mail class="input-icon h-4 w-4" />
61
+ <Label for="login-email">{t('auth.email')}</Label>
62
+ <div class="relative">
63
+ <Mail class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-[1]" />
61
64
  <Input
62
65
  id="login-email"
63
66
  type="email"
@@ -69,58 +72,37 @@
69
72
  </div>
70
73
  </div>
71
74
 
72
- <div class="space-y-2">
73
- <div class="flex items-center justify-between">
74
- <label for="login-password" class="text-sm font-medium text-foreground">{t('auth.password')}</label>
75
+ <div>
76
+ <div class="flex items-center justify-between mb-2">
77
+ <Label for="login-password">{t('auth.password')}</Label>
75
78
  {#if authProvider.forgotPassword}
76
- <button
77
- type="button"
78
- class="text-xs text-primary hover:underline"
79
- onclick={() => navigate('/forgot-password')}
80
- >{t('auth.forgotPasswordLink')}</button>
79
+ <Button variant="link" class="text-xs h-auto p-0" onclick={() => navigate('/forgot-password')}>
80
+ {t('auth.forgotPasswordLink')}
81
+ </Button>
81
82
  {/if}
82
83
  </div>
83
- <div class="input-with-icon">
84
- <Lock class="input-icon h-4 w-4" />
85
- <Input
86
- id="login-password"
87
- type={showPassword ? 'text' : 'password'}
88
- placeholder="••••••••"
89
- bind:value={password}
90
- class="pl-9 pr-9"
91
- autocomplete="current-password"
92
- />
93
- <button
94
- type="button"
95
- class="password-toggle"
96
- onclick={() => showPassword = !showPassword}
97
- tabindex={-1}
98
- >
99
- {#if showPassword}
100
- <EyeOff class="h-4 w-4" />
101
- {:else}
102
- <Eye class="h-4 w-4" />
103
- {/if}
104
- </button>
105
- </div>
84
+ <PasswordInput
85
+ id="login-password"
86
+ label=""
87
+ bind:value={password}
88
+ autocomplete="current-password"
89
+ />
106
90
  </div>
107
91
 
108
92
  <Button type="submit" class="w-full" disabled={login.isLoading}>
109
93
  {#if login.isLoading}
110
- <span class="loading-spinner"></span>
94
+ <Loader2 class="h-4 w-4 animate-spin mr-2" />
111
95
  {/if}
112
96
  {t('auth.loginButton')}
113
97
  </Button>
114
98
  </form>
115
99
 
116
100
  {#if authProvider.register}
117
- <div class="auth-link">
101
+ <div class="flex items-center justify-center gap-1 mt-5 pt-5 border-t">
118
102
  <span class="text-sm text-muted-foreground">{t('auth.noAccount')}</span>
119
- <button
120
- type="button"
121
- class="text-sm text-primary hover:underline font-medium"
122
- onclick={() => navigate('/register')}
123
- >{t('auth.register')}</button>
103
+ <Button variant="link" class="text-sm h-auto p-0 font-medium" onclick={() => navigate('/register')}>
104
+ {t('auth.register')}
105
+ </Button>
124
106
  </div>
125
107
  {/if}
126
108
  </Card.CardContent>
@@ -131,106 +113,3 @@
131
113
  </p>
132
114
  </div>
133
115
  </div>
134
-
135
- <style>
136
- .login-page {
137
- min-height: 100vh;
138
- display: flex;
139
- align-items: center;
140
- justify-content: center;
141
- background: linear-gradient(135deg, hsl(var(--primary) / 0.08) 0%, hsl(var(--background)) 50%, hsl(var(--primary) / 0.04) 100%);
142
- padding: 1rem;
143
- }
144
-
145
- .login-container {
146
- width: 100%;
147
- max-width: 420px;
148
- }
149
-
150
- :global(.login-card) {
151
- backdrop-filter: blur(20px);
152
- border: 1px solid hsl(var(--border) / 0.5);
153
- box-shadow: 0 8px 32px hsl(var(--primary) / 0.08), 0 2px 8px hsl(0 0% 0% / 0.06);
154
- }
155
-
156
- :global(.login-header) {
157
- text-align: center;
158
- padding-bottom: 0.5rem;
159
- }
160
-
161
- .login-icon {
162
- display: inline-flex;
163
- align-items: center;
164
- justify-content: center;
165
- width: 48px;
166
- height: 48px;
167
- border-radius: 12px;
168
- background: hsl(var(--primary) / 0.1);
169
- color: hsl(var(--primary));
170
- margin: 0 auto 0.75rem;
171
- }
172
-
173
- .input-with-icon {
174
- position: relative;
175
- }
176
-
177
- :global(.input-icon) {
178
- position: absolute;
179
- left: 0.75rem;
180
- top: 50%;
181
- transform: translateY(-50%);
182
- color: hsl(var(--muted-foreground));
183
- pointer-events: none;
184
- z-index: 1;
185
- }
186
-
187
- .password-toggle {
188
- position: absolute;
189
- right: 0.75rem;
190
- top: 50%;
191
- transform: translateY(-50%);
192
- color: hsl(var(--muted-foreground));
193
- background: none;
194
- border: none;
195
- cursor: pointer;
196
- padding: 2px;
197
- z-index: 1;
198
- }
199
- .password-toggle:hover {
200
- color: hsl(var(--foreground));
201
- }
202
-
203
- .error-alert {
204
- padding: 0.75rem;
205
- border-radius: 0.5rem;
206
- background: hsl(var(--destructive) / 0.1);
207
- border: 1px solid hsl(var(--destructive) / 0.3);
208
- color: hsl(var(--destructive));
209
- font-size: 0.875rem;
210
- }
211
-
212
- .loading-spinner {
213
- display: inline-block;
214
- width: 16px;
215
- height: 16px;
216
- border: 2px solid transparent;
217
- border-top-color: currentColor;
218
- border-radius: 50%;
219
- animation: spin 0.6s linear infinite;
220
- margin-right: 0.5rem;
221
- }
222
-
223
- @keyframes spin {
224
- to { transform: rotate(360deg); }
225
- }
226
-
227
- .auth-link {
228
- display: flex;
229
- align-items: center;
230
- justify-content: center;
231
- gap: 0.25rem;
232
- margin-top: 1.25rem;
233
- padding-top: 1.25rem;
234
- border-top: 1px solid hsl(var(--border));
235
- }
236
- </style>
@@ -2,6 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { FieldDefinition } from '@svadmin/core';
4
4
  import { getResource, useForm } from '@svadmin/core';
5
+ import { t } from '@svadmin/core/i18n';
5
6
  import * as Dialog from './ui/dialog/index.js';
6
7
  import AutoForm from './AutoForm.svelte';
7
8
 
@@ -14,18 +15,21 @@
14
15
  }>();
15
16
 
16
17
  const resource = $derived(getResource(resourceName));
18
+ const fieldCount = $derived(resource.fields.filter(f => f.showInForm !== false).length);
19
+ const dialogWidth = $derived(fieldCount > 8 ? 'sm:max-w-3xl' : fieldCount > 5 ? 'sm:max-w-2xl' : 'sm:max-w-lg');
17
20
 
18
21
  function handleClose() {
19
22
  open = false;
23
+ onSuccess?.();
20
24
  }
21
25
  </script>
22
26
 
23
27
  {#if open}
24
28
  <Dialog.Dialog bind:open>
25
- <Dialog.DialogContent class="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
29
+ <Dialog.DialogContent class="{dialogWidth} max-h-[85vh] overflow-y-auto">
26
30
  <Dialog.DialogHeader>
27
31
  <Dialog.DialogTitle>
28
- {mode === 'create' ? `Create ${resource.label}` : `Edit ${resource.label}`}
32
+ {mode === 'create' ? `${t('common.create')}${resource.label}` : `${t('common.edit')}${resource.label}`}
29
33
  </Dialog.DialogTitle>
30
34
  </Dialog.DialogHeader>
31
35
  <AutoForm {resourceName} {mode} {id} />
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { fade } from 'svelte/transition';
2
3
  import { Skeleton } from './ui/skeleton';
3
4
 
4
5
  let {
@@ -10,7 +11,7 @@
10
11
  }>();
11
12
  </script>
12
13
 
13
- <div class="w-full space-y-6">
14
+ <div class="w-full space-y-6" out:fade={{ duration: 200 }}>
14
15
  <!-- Header Skeleton -->
15
16
  <div class="flex items-center justify-between">
16
17
  <div class="space-y-2">
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import { Input } from './ui/input/index.js';
3
+ import { Button } from './ui/button/index.js';
4
+ import TooltipButton from './TooltipButton.svelte';
5
+ import { Label } from './ui/label/index.js';
6
+ import { Progress } from './ui/progress/index.js';
7
+ import { Lock, Eye, EyeOff } from 'lucide-svelte';
8
+
9
+ interface Props {
10
+ id: string;
11
+ label: string;
12
+ value: string;
13
+ placeholder?: string;
14
+ autocomplete?: string;
15
+ showStrength?: boolean;
16
+ class?: string;
17
+ }
18
+
19
+ let {
20
+ id,
21
+ label,
22
+ value = $bindable(''),
23
+ placeholder = '••••••••',
24
+ autocomplete = 'current-password',
25
+ showStrength = false,
26
+ class: className = '',
27
+ }: Props = $props();
28
+
29
+ let showPassword = $state(false);
30
+
31
+ // Password strength calculation
32
+ const strength = $derived.by(() => {
33
+ if (!showStrength || !value) return 0;
34
+ let score = 0;
35
+ if (value.length >= 8) score += 25;
36
+ if (value.length >= 12) score += 10;
37
+ if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score += 25;
38
+ if (/\d/.test(value)) score += 20;
39
+ if (/[^a-zA-Z0-9]/.test(value)) score += 20;
40
+ return Math.min(score, 100);
41
+ });
42
+
43
+ const strengthColor = $derived(
44
+ strength < 30 ? 'bg-destructive' :
45
+ strength < 60 ? 'bg-amber-500' :
46
+ strength < 80 ? 'bg-blue-500' : 'bg-emerald-500'
47
+ );
48
+
49
+ const strengthLabel = $derived(
50
+ strength < 30 ? 'Weak' :
51
+ strength < 60 ? 'Fair' :
52
+ strength < 80 ? 'Good' : 'Strong'
53
+ );
54
+ </script>
55
+
56
+ <div class="space-y-2 {className}">
57
+ <Label for={id}>{label}</Label>
58
+ <div class="relative">
59
+ <Lock class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none z-[1]" />
60
+ <Input
61
+ {id}
62
+ type={showPassword ? 'text' : 'password'}
63
+ {placeholder}
64
+ bind:value
65
+ class="pl-9 pr-9"
66
+ {autocomplete}
67
+ />
68
+ <TooltipButton
69
+ tooltip={showPassword ? 'Hide password' : 'Show password'}
70
+ variant="ghost"
71
+ size="icon"
72
+ type="button"
73
+ class="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 z-[1]"
74
+ onclick={() => showPassword = !showPassword}
75
+ tabindex={-1}
76
+ >
77
+ {#if showPassword}
78
+ <EyeOff class="h-4 w-4" />
79
+ {:else}
80
+ <Eye class="h-4 w-4" />
81
+ {/if}
82
+ </TooltipButton>
83
+ </div>
84
+ {#if showStrength && value}
85
+ <div class="flex items-center gap-2">
86
+ <div class="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
87
+ <div
88
+ class="h-full rounded-full transition-all duration-300 {strengthColor}"
89
+ style="width: {strength}%"
90
+ ></div>
91
+ </div>
92
+ <span class="text-xs text-muted-foreground w-12 text-right">{strengthLabel}</span>
93
+ </div>
94
+ {/if}
95
+ </div>