@svadmin/ui 0.51.0 → 0.53.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 (33) hide show
  1. package/dist/components/BatchActionBar.svelte +119 -0
  2. package/dist/components/BatchActionBar.svelte.d.ts +15 -0
  3. package/dist/components/FilterDropdown.svelte +252 -0
  4. package/dist/components/FilterDropdown.svelte.d.ts +24 -0
  5. package/dist/components/content/AuditTimeline.svelte +86 -0
  6. package/dist/components/content/AuditTimeline.svelte.d.ts +20 -0
  7. package/dist/components/content/index.d.ts +2 -0
  8. package/dist/components/content/index.js +1 -0
  9. package/dist/components/fields/AvatarField.svelte +123 -0
  10. package/dist/components/fields/AvatarField.svelte.d.ts +16 -0
  11. package/dist/components/fields/BooleanField.svelte +90 -8
  12. package/dist/components/fields/BooleanField.svelte.d.ts +8 -0
  13. package/dist/components/fields/CodeField.svelte +96 -0
  14. package/dist/components/fields/CodeField.svelte.d.ts +13 -0
  15. package/dist/components/fields/CopyField.svelte +102 -0
  16. package/dist/components/fields/CopyField.svelte.d.ts +15 -0
  17. package/dist/components/fields/DateField.svelte +74 -4
  18. package/dist/components/fields/DateField.svelte.d.ts +4 -0
  19. package/dist/components/fields/DateRangeField.svelte +98 -0
  20. package/dist/components/fields/DateRangeField.svelte.d.ts +22 -0
  21. package/dist/components/fields/NumberField.svelte +72 -4
  22. package/dist/components/fields/NumberField.svelte.d.ts +9 -0
  23. package/dist/components/fields/PercentField.svelte +92 -0
  24. package/dist/components/fields/PercentField.svelte.d.ts +13 -0
  25. package/dist/components/fields/RatingField.svelte +76 -0
  26. package/dist/components/fields/RatingField.svelte.d.ts +12 -0
  27. package/dist/components/fields/TagField.svelte +90 -6
  28. package/dist/components/fields/TagField.svelte.d.ts +9 -0
  29. package/dist/components/fields/UrlField.svelte +43 -7
  30. package/dist/components/fields/UrlField.svelte.d.ts +5 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.js +9 -0
  33. package/package.json +1 -1
@@ -0,0 +1,119 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { CheckCircle2, X } from '@lucide/svelte';
4
+ import { Button } from './ui/button/index.js';
5
+ import { Badge } from './ui/badge/index.js';
6
+ import { cn } from '../utils.js';
7
+
8
+ interface Props {
9
+ selectedCount?: number;
10
+ totalCount?: number;
11
+ selectedLabel?: string;
12
+ clearLabel?: string;
13
+ variant?: 'default' | 'floating' | 'subtle';
14
+ onclear?: () => void;
15
+ actions?: Snippet;
16
+ children?: Snippet;
17
+ class?: string;
18
+ }
19
+
20
+ let {
21
+ selectedCount = 0,
22
+ totalCount,
23
+ selectedLabel,
24
+ clearLabel = 'Clear',
25
+ variant = 'default',
26
+ onclear,
27
+ actions,
28
+ children,
29
+ class: className = '',
30
+ }: Props = $props();
31
+
32
+ const isVisible = $derived(selectedCount > 0);
33
+ const displayText = $derived.by(() => {
34
+ if (selectedLabel) {
35
+ return selectedLabel.replace('{count}', String(selectedCount));
36
+ }
37
+ return totalCount !== undefined
38
+ ? `${selectedCount} of ${totalCount} selected`
39
+ : `${selectedCount} selected`;
40
+ });
41
+ </script>
42
+
43
+ {#if isVisible}
44
+ {#if variant === 'floating'}
45
+ <div
46
+ class={cn(
47
+ 'fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-lg border border-border/80 bg-background/95 px-4 py-2.5 shadow-2xl backdrop-blur-md transition-all duration-200 animate-in fade-in-0 slide-in-from-bottom-4',
48
+ className
49
+ )}
50
+ role="region"
51
+ aria-label="Batch Actions"
52
+ >
53
+ <div class="flex items-center gap-2 pr-2 border-r border-border/60">
54
+ <CheckCircle2 class="h-4 w-4 text-primary" />
55
+ <span class="text-xs font-medium text-foreground">{displayText}</span>
56
+ </div>
57
+
58
+ {#if actions}
59
+ <div class="flex items-center gap-1.5">
60
+ {@render actions()}
61
+ </div>
62
+ {/if}
63
+
64
+ {#if children}
65
+ <div class="flex items-center gap-1.5">
66
+ {@render children()}
67
+ </div>
68
+ {/if}
69
+
70
+ {#if onclear}
71
+ <Button
72
+ variant="ghost"
73
+ size="sm"
74
+ class="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
75
+ onclick={onclear}
76
+ aria-label={clearLabel}
77
+ >
78
+ <X class="h-3.5 w-3.5 mr-1" />
79
+ {clearLabel}
80
+ </Button>
81
+ {/if}
82
+ </div>
83
+ {:else}
84
+ <div
85
+ class={cn(
86
+ 'flex flex-wrap items-center justify-between gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-xs transition-all duration-150',
87
+ variant === 'subtle' ? 'border-border/60 bg-muted/40' : '',
88
+ className
89
+ )}
90
+ role="region"
91
+ aria-label="Batch Actions"
92
+ >
93
+ <div class="flex items-center gap-2">
94
+ <Badge variant="secondary" class="h-5 px-1.5 font-semibold text-[11px] bg-primary/15 text-primary border-primary/20">
95
+ {selectedCount}
96
+ </Badge>
97
+ <span class="font-medium text-foreground">{displayText}</span>
98
+ {#if onclear}
99
+ <button
100
+ type="button"
101
+ class="text-muted-foreground hover:text-foreground underline underline-offset-2 ml-1 text-xs cursor-pointer focus:outline-none"
102
+ onclick={onclear}
103
+ >
104
+ {clearLabel}
105
+ </button>
106
+ {/if}
107
+ </div>
108
+
109
+ <div class="flex items-center gap-2">
110
+ {#if actions}
111
+ {@render actions()}
112
+ {/if}
113
+ {#if children}
114
+ {@render children()}
115
+ {/if}
116
+ </div>
117
+ </div>
118
+ {/if}
119
+ {/if}
@@ -0,0 +1,15 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ selectedCount?: number;
4
+ totalCount?: number;
5
+ selectedLabel?: string;
6
+ clearLabel?: string;
7
+ variant?: 'default' | 'floating' | 'subtle';
8
+ onclear?: () => void;
9
+ actions?: Snippet;
10
+ children?: Snippet;
11
+ class?: string;
12
+ }
13
+ declare const BatchActionBar: import("svelte").Component<Props, {}, "">;
14
+ type BatchActionBar = ReturnType<typeof BatchActionBar>;
15
+ export default BatchActionBar;
@@ -0,0 +1,252 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { Filter, X, Search } from '@lucide/svelte';
4
+ import { Button } from './ui/button/index.js';
5
+ import { Input } from './ui/input/index.js';
6
+ import { clickOutside } from '../actions.js';
7
+ import { cn } from '../utils.js';
8
+
9
+ interface OptionItem {
10
+ label: string;
11
+ value: string | number;
12
+ }
13
+
14
+ interface Props {
15
+ title?: string;
16
+ open?: boolean;
17
+ active?: boolean;
18
+ value?: unknown;
19
+ options?: OptionItem[];
20
+ placeholder?: string;
21
+ applyLabel?: string;
22
+ resetLabel?: string;
23
+ align?: 'left' | 'right';
24
+ onapply?: (value: unknown) => void;
25
+ onreset?: () => void;
26
+ children?: Snippet;
27
+ trigger?: Snippet;
28
+ class?: string;
29
+ }
30
+
31
+ let {
32
+ title = 'Filter',
33
+ open = $bindable(false),
34
+ active,
35
+ value = $bindable(undefined),
36
+ options,
37
+ placeholder = 'Search...',
38
+ applyLabel = 'Apply',
39
+ resetLabel = 'Reset',
40
+ align = 'right',
41
+ onapply,
42
+ onreset,
43
+ children,
44
+ trigger,
45
+ class: className = '',
46
+ }: Props = $props();
47
+
48
+ type OptionValue = OptionItem['value'];
49
+
50
+ function isOptionValue(candidate: unknown): candidate is OptionValue {
51
+ return typeof candidate === 'string' || typeof candidate === 'number';
52
+ }
53
+
54
+ function getTextValue(candidate: unknown): string {
55
+ return typeof candidate === 'string' ? candidate : '';
56
+ }
57
+
58
+ function getSelectedValues(candidate: unknown): OptionValue[] {
59
+ if (Array.isArray(candidate)) return candidate.filter(isOptionValue);
60
+ return isOptionValue(candidate) && candidate !== '' ? [candidate] : [];
61
+ }
62
+
63
+ let internalTextValue = $state(getTextValue(value));
64
+ let internalSelectedValues = $state<OptionValue[]>(getSelectedValues(value));
65
+ let optionSearch = $state('');
66
+
67
+ // Keep the editable draft aligned with controlled value changes.
68
+ $effect(() => {
69
+ internalTextValue = getTextValue(value);
70
+ internalSelectedValues = getSelectedValues(value);
71
+ });
72
+
73
+ const isFilterActive = $derived(
74
+ active !== undefined
75
+ ? active
76
+ : Array.isArray(value)
77
+ ? value.length > 0
78
+ : value != null && value !== ''
79
+ );
80
+
81
+ const filteredOptions = $derived(
82
+ options
83
+ ? optionSearch
84
+ ? options.filter((opt) => opt.label.toLowerCase().includes(optionSearch.toLowerCase()))
85
+ : options
86
+ : []
87
+ );
88
+
89
+ function restoreDraft() {
90
+ internalTextValue = getTextValue(value);
91
+ internalSelectedValues = getSelectedValues(value);
92
+ optionSearch = '';
93
+ }
94
+
95
+ function handleClose() {
96
+ restoreDraft();
97
+ open = false;
98
+ }
99
+
100
+ function handleClickOutside() {
101
+ if (open) handleClose();
102
+ }
103
+
104
+ function handleTriggerClick() {
105
+ if (open) {
106
+ handleClose();
107
+ } else {
108
+ open = true;
109
+ }
110
+ }
111
+
112
+ function toggleOption(optValue: OptionValue) {
113
+ if (internalSelectedValues.includes(optValue)) {
114
+ internalSelectedValues = internalSelectedValues.filter((v) => v !== optValue);
115
+ } else {
116
+ internalSelectedValues = [...internalSelectedValues, optValue];
117
+ }
118
+ }
119
+
120
+ function handleApply() {
121
+ if (options && options.length > 0) {
122
+ const nextValue = [...internalSelectedValues];
123
+ value = nextValue;
124
+ onapply?.(nextValue);
125
+ } else if (!children) {
126
+ value = internalTextValue;
127
+ onapply?.(internalTextValue);
128
+ } else {
129
+ onapply?.(value);
130
+ }
131
+ optionSearch = '';
132
+ open = false;
133
+ }
134
+
135
+ function handleReset() {
136
+ internalTextValue = '';
137
+ internalSelectedValues = [];
138
+ optionSearch = '';
139
+ value = options && options.length > 0 ? [] : undefined;
140
+ onreset?.();
141
+ open = false;
142
+ }
143
+ </script>
144
+
145
+ <div class={cn('relative inline-block text-left', className)} use:clickOutside={handleClickOutside}>
146
+ {#if trigger}
147
+ {@render trigger()}
148
+ {:else}
149
+ <Button
150
+ variant={isFilterActive ? 'secondary' : 'ghost'}
151
+ size="icon-sm"
152
+ class={cn(
153
+ 'relative h-7 w-7 rounded-sm p-0 transition-colors',
154
+ isFilterActive ? 'text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20' : 'text-muted-foreground hover:text-foreground'
155
+ )}
156
+ onclick={handleTriggerClick}
157
+ aria-label={title}
158
+ aria-expanded={open}
159
+ aria-haspopup="dialog"
160
+ title={title}
161
+ >
162
+ <Filter class="h-3.5 w-3.5" />
163
+ {#if isFilterActive}
164
+ <span class="absolute -top-0.5 -right-0.5 flex h-2 w-2">
165
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
166
+ <span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
167
+ </span>
168
+ {/if}
169
+ </Button>
170
+ {/if}
171
+
172
+ {#if open}
173
+ <div
174
+ class={cn(
175
+ 'absolute z-50 mt-1.5 w-64 rounded-md border border-border bg-popover text-popover-foreground shadow-lg outline-none animate-in fade-in-0 zoom-in-95',
176
+ align === 'right' ? 'right-0 origin-top-right' : 'left-0 origin-top-left'
177
+ )}
178
+ role="dialog"
179
+ aria-label={title}
180
+ >
181
+ <div class="flex items-center justify-between border-b border-border/60 px-3 py-2">
182
+ <span class="text-xs font-semibold text-foreground">{title}</span>
183
+ <button
184
+ type="button"
185
+ class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
186
+ onclick={handleClose}
187
+ aria-label="Close"
188
+ >
189
+ <X class="h-3.5 w-3.5" />
190
+ </button>
191
+ </div>
192
+
193
+ <div class="p-3">
194
+ {#if children}
195
+ {@render children()}
196
+ {:else if options && options.length > 0}
197
+ {#if options.length > 5}
198
+ <div class="relative mb-2">
199
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
200
+ <Input
201
+ bind:value={optionSearch}
202
+ placeholder="Search options..."
203
+ class="h-7 pl-7 text-xs"
204
+ />
205
+ </div>
206
+ {/if}
207
+ <div class="max-h-44 overflow-y-auto space-y-1.5 pr-1">
208
+ {#each filteredOptions as opt (opt.value)}
209
+ <label class="flex items-center gap-2 rounded px-1.5 py-1 text-xs hover:bg-accent cursor-pointer">
210
+ <input
211
+ type="checkbox"
212
+ checked={internalSelectedValues.includes(opt.value)}
213
+ onchange={() => toggleOption(opt.value)}
214
+ class="h-3.5 w-3.5 rounded border-muted-foreground text-primary focus:ring-primary"
215
+ />
216
+ <span class="flex-1 truncate">{opt.label}</span>
217
+ </label>
218
+ {:else}
219
+ <div class="py-2 text-center text-xs text-muted-foreground">No options match</div>
220
+ {/each}
221
+ </div>
222
+ {:else}
223
+ <Input
224
+ bind:value={internalTextValue}
225
+ {placeholder}
226
+ class="h-8 text-xs"
227
+ onkeydown={(e) => { if (e.key === 'Enter') handleApply(); }}
228
+ />
229
+ {/if}
230
+ </div>
231
+
232
+ <div class="flex items-center justify-between border-t border-border/60 bg-muted/30 px-3 py-2">
233
+ <Button
234
+ variant="ghost"
235
+ size="sm"
236
+ class="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
237
+ onclick={handleReset}
238
+ >
239
+ {resetLabel}
240
+ </Button>
241
+ <Button
242
+ variant="default"
243
+ size="sm"
244
+ class="h-7 px-3 text-xs"
245
+ onclick={handleApply}
246
+ >
247
+ {applyLabel}
248
+ </Button>
249
+ </div>
250
+ </div>
251
+ {/if}
252
+ </div>
@@ -0,0 +1,24 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface OptionItem {
3
+ label: string;
4
+ value: string | number;
5
+ }
6
+ interface Props {
7
+ title?: string;
8
+ open?: boolean;
9
+ active?: boolean;
10
+ value?: unknown;
11
+ options?: OptionItem[];
12
+ placeholder?: string;
13
+ applyLabel?: string;
14
+ resetLabel?: string;
15
+ align?: 'left' | 'right';
16
+ onapply?: (value: unknown) => void;
17
+ onreset?: () => void;
18
+ children?: Snippet;
19
+ trigger?: Snippet;
20
+ class?: string;
21
+ }
22
+ declare const FilterDropdown: import("svelte").Component<Props, {}, "value" | "open">;
23
+ type FilterDropdown = ReturnType<typeof FilterDropdown>;
24
+ export default FilterDropdown;
@@ -0,0 +1,86 @@
1
+ <script module lang="ts">
2
+ import type { Status } from './StatusBadge.svelte';
3
+
4
+ export interface TimelineItem {
5
+ id: string;
6
+ title: string;
7
+ description?: string;
8
+ timestamp?: string;
9
+ actor?: string;
10
+ status?: Status;
11
+ tag?: string;
12
+ meta?: Record<string, unknown>;
13
+ }
14
+ </script>
15
+
16
+ <script lang="ts">
17
+ import { cn } from '../../utils.js';
18
+ import StatusBadge from './StatusBadge.svelte';
19
+ import DataState from './DataState.svelte';
20
+
21
+ interface Props {
22
+ items?: TimelineItem[];
23
+ emptyTitle?: string;
24
+ emptyDescription?: string;
25
+ class?: string;
26
+ }
27
+
28
+ let {
29
+ items = [],
30
+ emptyTitle = 'No timeline events',
31
+ emptyDescription = 'Activity events and status transitions will appear here.',
32
+ class: className = '',
33
+ }: Props = $props();
34
+
35
+ const dotClass: Record<Status, string> = {
36
+ success: 'bg-success border-success/30 text-success',
37
+ warning: 'bg-warning border-warning/30 text-warning-foreground',
38
+ danger: 'bg-destructive border-destructive/30 text-destructive',
39
+ info: 'bg-primary border-primary/30 text-primary',
40
+ neutral: 'bg-muted-foreground border-border text-muted-foreground',
41
+ };
42
+ </script>
43
+
44
+ <div class={cn('space-y-4', className)}>
45
+ {#if items.length === 0}
46
+ <DataState state="empty" title={emptyTitle} description={emptyDescription} />
47
+ {:else}
48
+ <div class="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-border">
49
+ {#each items as item (item.id)}
50
+ {@const statusTone = item.status ?? 'info'}
51
+ <div class="relative flex flex-col gap-1 text-sm group">
52
+ <span
53
+ class={cn(
54
+ 'absolute -left-6 top-1 flex h-4 w-4 items-center justify-center rounded-full border-2 bg-background transition-transform group-hover:scale-110',
55
+ item.status ? dotClass[statusTone] : 'border-border bg-muted'
56
+ )}
57
+ aria-hidden="true"
58
+ ></span>
59
+
60
+ <div class="flex flex-wrap items-center justify-between gap-2">
61
+ <div class="flex items-center gap-2">
62
+ <span class="font-medium text-foreground">{item.title}</span>
63
+ {#if item.actor}
64
+ <span class="text-xs text-muted-foreground">by {item.actor}</span>
65
+ {/if}
66
+ {#if item.status}
67
+ <StatusBadge status={item.status} label={item.tag ?? item.status} class="text-[11px] py-0 h-4" />
68
+ {:else if item.tag}
69
+ <span class="inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground bg-muted">
70
+ {item.tag}
71
+ </span>
72
+ {/if}
73
+ </div>
74
+ {#if item.timestamp}
75
+ <time class="text-xs tabular-nums text-muted-foreground font-mono">{item.timestamp}</time>
76
+ {/if}
77
+ </div>
78
+
79
+ {#if item.description}
80
+ <p class="text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap">{item.description}</p>
81
+ {/if}
82
+ </div>
83
+ {/each}
84
+ </div>
85
+ {/if}
86
+ </div>
@@ -0,0 +1,20 @@
1
+ import type { Status } from './StatusBadge.svelte';
2
+ export interface TimelineItem {
3
+ id: string;
4
+ title: string;
5
+ description?: string;
6
+ timestamp?: string;
7
+ actor?: string;
8
+ status?: Status;
9
+ tag?: string;
10
+ meta?: Record<string, unknown>;
11
+ }
12
+ interface Props {
13
+ items?: TimelineItem[];
14
+ emptyTitle?: string;
15
+ emptyDescription?: string;
16
+ class?: string;
17
+ }
18
+ declare const AuditTimeline: import("svelte").Component<Props, {}, "">;
19
+ type AuditTimeline = ReturnType<typeof AuditTimeline>;
20
+ export default AuditTimeline;
@@ -41,3 +41,5 @@ export { default as TwoFactorStepper } from './TwoFactorStepper.svelte';
41
41
  export { default as SystemErrorState } from './SystemErrorState.svelte';
42
42
  export { default as FeedbackNotice } from './FeedbackNotice.svelte';
43
43
  export type { FeedbackNoticeTone, FeedbackNoticePriority } from './FeedbackNotice.svelte';
44
+ export { default as AuditTimeline } from './AuditTimeline.svelte';
45
+ export type { TimelineItem } from './AuditTimeline.svelte';
@@ -29,3 +29,4 @@ export { default as OtpInput } from './OtpInput.svelte';
29
29
  export { default as TwoFactorStepper } from './TwoFactorStepper.svelte';
30
30
  export { default as SystemErrorState } from './SystemErrorState.svelte';
31
31
  export { default as FeedbackNotice } from './FeedbackNotice.svelte';
32
+ export { default as AuditTimeline } from './AuditTimeline.svelte';
@@ -0,0 +1,123 @@
1
+ <script module lang="ts">
2
+ export type AvatarStatus = 'online' | 'offline' | 'busy' | 'away' | 'success' | 'warning' | 'error' | 'neutral';
3
+ export type AvatarSize = 'xs' | 'sm' | 'default' | 'lg';
4
+ </script>
5
+
6
+ <script lang="ts">
7
+ import { cn } from '../../utils.js';
8
+
9
+ interface Props {
10
+ src?: string | null | undefined;
11
+ name?: string | null | undefined;
12
+ subtitle?: string | null | undefined;
13
+ status?: AvatarStatus;
14
+ size?: AvatarSize;
15
+ shape?: 'circle' | 'square';
16
+ showName?: boolean;
17
+ nullLabel?: string;
18
+ class?: string;
19
+ }
20
+
21
+ let {
22
+ src,
23
+ name,
24
+ subtitle,
25
+ status,
26
+ size = 'sm',
27
+ shape = 'circle',
28
+ showName = false,
29
+ nullLabel = '—',
30
+ class: className = '',
31
+ }: Props = $props();
32
+
33
+ let imgError = $state(false);
34
+
35
+ $effect(() => {
36
+ void src;
37
+ imgError = false;
38
+ });
39
+
40
+ const sizeClasses: Record<AvatarSize, { container: string; text: string; dot: string }> = {
41
+ xs: { container: 'h-5 w-5', text: 'text-[10px]', dot: 'h-1.5 w-1.5' },
42
+ sm: { container: 'h-7 w-7', text: 'text-xs', dot: 'h-2 w-2' },
43
+ default: { container: 'h-9 w-9', text: 'text-sm', dot: 'h-2.5 w-2.5' },
44
+ lg: { container: 'h-11 w-11', text: 'text-base', dot: 'h-3 w-3' },
45
+ };
46
+
47
+ const statusColors: Record<AvatarStatus, string> = {
48
+ online: 'bg-emerald-500',
49
+ success: 'bg-emerald-500',
50
+ busy: 'bg-rose-500',
51
+ error: 'bg-rose-500',
52
+ warning: 'bg-amber-500',
53
+ away: 'bg-amber-500',
54
+ offline: 'bg-slate-400',
55
+ neutral: 'bg-slate-400',
56
+ };
57
+
58
+ function getInitials(value?: string | null): string {
59
+ const trimmed = value?.trim();
60
+ if (!trimmed) return '?';
61
+
62
+ const parts = trimmed.split(/\s+/);
63
+ if (parts.length >= 2) {
64
+ return `${parts[0][0]}${parts.at(-1)?.[0] ?? ''}`.toUpperCase();
65
+ }
66
+ return trimmed.slice(0, 2).toUpperCase();
67
+ }
68
+
69
+ const initials = $derived(getInitials(name));
70
+ const hasContent = $derived(Boolean(src || name?.trim()));
71
+ </script>
72
+
73
+ {#if !hasContent}
74
+ <span class={cn('field-avatar text-muted-foreground text-sm', className)}>{nullLabel}</span>
75
+ {:else}
76
+ <div class={cn('field-avatar inline-flex items-center gap-2.5', className)}>
77
+ <div class="relative inline-block shrink-0">
78
+ <div
79
+ class={cn(
80
+ 'flex items-center justify-center overflow-hidden bg-muted font-medium text-muted-foreground select-none',
81
+ shape === 'circle' ? 'rounded-full' : 'rounded-md',
82
+ sizeClasses[size].container,
83
+ sizeClasses[size].text
84
+ )}
85
+ >
86
+ {#if src && !imgError}
87
+ <img
88
+ {src}
89
+ alt={name?.trim() || 'Avatar'}
90
+ class="h-full w-full object-cover"
91
+ onerror={() => { imgError = true; }}
92
+ />
93
+ {:else}
94
+ <span>{initials}</span>
95
+ {/if}
96
+ </div>
97
+
98
+ {#if status}
99
+ <span
100
+ class={cn(
101
+ 'absolute bottom-0 right-0 rounded-full ring-2 ring-background',
102
+ statusColors[status],
103
+ sizeClasses[size].dot
104
+ )}
105
+ role="img"
106
+ aria-label={`Status: ${status}`}
107
+ title={`Status: ${status}`}
108
+ ></span>
109
+ {/if}
110
+ </div>
111
+
112
+ {#if showName && (name || subtitle)}
113
+ <div class="flex flex-col min-w-0 text-left">
114
+ {#if name}
115
+ <span class="truncate text-xs font-medium text-foreground leading-tight">{name}</span>
116
+ {/if}
117
+ {#if subtitle}
118
+ <span class="truncate text-[11px] text-muted-foreground leading-tight">{subtitle}</span>
119
+ {/if}
120
+ </div>
121
+ {/if}
122
+ </div>
123
+ {/if}
@@ -0,0 +1,16 @@
1
+ export type AvatarStatus = 'online' | 'offline' | 'busy' | 'away' | 'success' | 'warning' | 'error' | 'neutral';
2
+ export type AvatarSize = 'xs' | 'sm' | 'default' | 'lg';
3
+ interface Props {
4
+ src?: string | null | undefined;
5
+ name?: string | null | undefined;
6
+ subtitle?: string | null | undefined;
7
+ status?: AvatarStatus;
8
+ size?: AvatarSize;
9
+ shape?: 'circle' | 'square';
10
+ showName?: boolean;
11
+ nullLabel?: string;
12
+ class?: string;
13
+ }
14
+ declare const AvatarField: import("svelte").Component<Props, {}, "">;
15
+ type AvatarField = ReturnType<typeof AvatarField>;
16
+ export default AvatarField;