@svadmin/ui 0.52.0 → 0.53.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.
@@ -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,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;
@@ -0,0 +1,108 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { Copy, Check } from '@lucide/svelte';
4
+ import { Badge } from '../ui/badge/index.js';
5
+ import { cn } from '../../utils.js';
6
+
7
+ interface Props {
8
+ value?: string | number | Record<string, unknown> | unknown[] | null | undefined;
9
+ language?: string;
10
+ copyable?: boolean;
11
+ title?: string;
12
+ copiedTitle?: string;
13
+ nullLabel?: string;
14
+ maxHeight?: string;
15
+ class?: string;
16
+ }
17
+
18
+ let {
19
+ value,
20
+ language,
21
+ copyable = true,
22
+ title = 'Copy code',
23
+ copiedTitle = 'Copied!',
24
+ nullLabel = '—',
25
+ maxHeight = 'max-h-48',
26
+ class: className = '',
27
+ }: Props = $props();
28
+
29
+ let copied = $state(false);
30
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
31
+ let destroyed = false;
32
+
33
+ onDestroy(() => {
34
+ destroyed = true;
35
+ if (timeoutId) clearTimeout(timeoutId);
36
+ });
37
+
38
+ const formattedCode = $derived.by(() => {
39
+ if (value == null || value === '') return '';
40
+ if (typeof value === 'string') return value;
41
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
42
+ try {
43
+ return JSON.stringify(value, null, 2);
44
+ } catch {
45
+ return String(value);
46
+ }
47
+ });
48
+
49
+ async function handleCopy(event: MouseEvent) {
50
+ event.stopPropagation();
51
+ if (!formattedCode || !copyable) return;
52
+
53
+ const writeText = globalThis.navigator?.clipboard?.writeText;
54
+ if (!writeText) return;
55
+
56
+ try {
57
+ await writeText.call(globalThis.navigator.clipboard, formattedCode);
58
+ } catch {
59
+ return;
60
+ }
61
+
62
+ if (destroyed) return;
63
+ copied = true;
64
+ if (timeoutId) clearTimeout(timeoutId);
65
+ timeoutId = setTimeout(() => {
66
+ copied = false;
67
+ timeoutId = null;
68
+ }, 1500);
69
+ }
70
+ </script>
71
+
72
+ {#if !formattedCode}
73
+ <span class={cn('field-code text-muted-foreground text-sm', className)}>{nullLabel}</span>
74
+ {:else}
75
+ <div class={cn('field-code relative group rounded-md border border-border/80 bg-muted/40 text-xs font-mono', className)}>
76
+ {#if language || copyable}
77
+ <div class="flex items-center justify-between border-b border-border/60 bg-muted/60 px-2.5 py-1 text-[11px] text-muted-foreground">
78
+ {#if language}
79
+ <Badge variant="outline" class="h-4 px-1 text-[10px] font-mono">
80
+ {language.toUpperCase()}
81
+ </Badge>
82
+ {:else}
83
+ <span></span>
84
+ {/if}
85
+
86
+ {#if copyable}
87
+ <button
88
+ type="button"
89
+ class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors focus:outline-none focus:ring-1 focus:ring-ring"
90
+ onclick={handleCopy}
91
+ title={copied ? copiedTitle : title}
92
+ aria-label={copied ? copiedTitle : title}
93
+ >
94
+ {#if copied}
95
+ <Check class="h-3 w-3 text-success animate-in zoom-in-50" />
96
+ <span class="text-[10px] text-success">{copiedTitle}</span>
97
+ {:else}
98
+ <Copy class="h-3 w-3" />
99
+ <span class="text-[10px]">{title}</span>
100
+ {/if}
101
+ </button>
102
+ {/if}
103
+ </div>
104
+ {/if}
105
+
106
+ <pre class={cn('p-2.5 overflow-x-auto overflow-y-auto leading-relaxed select-all', maxHeight)}><code>{formattedCode}</code></pre>
107
+ </div>
108
+ {/if}
@@ -0,0 +1,13 @@
1
+ interface Props {
2
+ value?: string | number | Record<string, unknown> | unknown[] | null | undefined;
3
+ language?: string;
4
+ copyable?: boolean;
5
+ title?: string;
6
+ copiedTitle?: string;
7
+ nullLabel?: string;
8
+ maxHeight?: string;
9
+ class?: string;
10
+ }
11
+ declare const CodeField: import("svelte").Component<Props, {}, "">;
12
+ type CodeField = ReturnType<typeof CodeField>;
13
+ export default CodeField;
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { Copy, Check } from '@lucide/svelte';
4
+ import { cn } from '../../utils.js';
5
+
6
+ interface Props {
7
+ value?: string | number | null | undefined;
8
+ displayValue?: string;
9
+ masked?: boolean;
10
+ monospace?: boolean;
11
+ copyable?: boolean;
12
+ title?: string;
13
+ copiedTitle?: string;
14
+ nullLabel?: string;
15
+ oncopy?: (value: string) => void;
16
+ class?: string;
17
+ }
18
+
19
+ let {
20
+ value,
21
+ displayValue,
22
+ masked = false,
23
+ monospace = true,
24
+ copyable = true,
25
+ title = 'Copy',
26
+ copiedTitle = 'Copied!',
27
+ nullLabel = '—',
28
+ oncopy,
29
+ class: className = '',
30
+ }: Props = $props();
31
+
32
+ let copied = $state(false);
33
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
34
+ let destroyed = false;
35
+
36
+ onDestroy(() => {
37
+ destroyed = true;
38
+ if (timeoutId) clearTimeout(timeoutId);
39
+ });
40
+
41
+ function maskText(str: string): string {
42
+ if (str.length <= 8) return '****';
43
+ return `${str.slice(0, 4)}...${str.slice(-4)}`;
44
+ }
45
+
46
+ const rawString = $derived(value != null && value !== '' ? String(value) : '');
47
+ const displayText = $derived.by(() => {
48
+ if (!rawString) return nullLabel;
49
+ if (displayValue) return displayValue;
50
+ if (masked) return maskText(rawString);
51
+ return rawString;
52
+ });
53
+
54
+ async function handleCopy(event: MouseEvent) {
55
+ event.stopPropagation();
56
+ if (!rawString || !copyable) return;
57
+
58
+ const writeText = globalThis.navigator?.clipboard?.writeText;
59
+ if (!writeText) return;
60
+
61
+ try {
62
+ await writeText.call(globalThis.navigator.clipboard, rawString);
63
+ } catch {
64
+ return;
65
+ }
66
+
67
+ if (destroyed) return;
68
+ copied = true;
69
+ oncopy?.(rawString);
70
+
71
+ if (timeoutId) clearTimeout(timeoutId);
72
+ timeoutId = setTimeout(() => {
73
+ copied = false;
74
+ timeoutId = null;
75
+ }, 1500);
76
+ }
77
+ </script>
78
+
79
+ {#if !rawString}
80
+ <span class={cn('field-copy text-muted-foreground text-sm', className)}>{nullLabel}</span>
81
+ {:else}
82
+ <span class={cn('field-copy inline-flex items-center gap-1.5 text-sm group', className)}>
83
+ <span class={cn(monospace ? 'font-mono text-xs' : '', 'truncate select-all')}>
84
+ {displayText}
85
+ </span>
86
+ {#if copyable}
87
+ <button
88
+ type="button"
89
+ class="inline-flex items-center justify-center h-5 w-5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus:outline-none focus:ring-1 focus:ring-ring"
90
+ onclick={handleCopy}
91
+ title={copied ? copiedTitle : title}
92
+ aria-label={copied ? copiedTitle : title}
93
+ >
94
+ {#if copied}
95
+ <Check class="h-3 w-3 text-success animate-in zoom-in-50 duration-150" />
96
+ {:else}
97
+ <Copy class="h-3 w-3 opacity-70 group-hover:opacity-100 transition-opacity" />
98
+ {/if}
99
+ </button>
100
+ {/if}
101
+ </span>
102
+ {/if}
@@ -0,0 +1,15 @@
1
+ interface Props {
2
+ value?: string | number | null | undefined;
3
+ displayValue?: string;
4
+ masked?: boolean;
5
+ monospace?: boolean;
6
+ copyable?: boolean;
7
+ title?: string;
8
+ copiedTitle?: string;
9
+ nullLabel?: string;
10
+ oncopy?: (value: string) => void;
11
+ class?: string;
12
+ }
13
+ declare const CopyField: import("svelte").Component<Props, {}, "">;
14
+ type CopyField = ReturnType<typeof CopyField>;
15
+ export default CopyField;
@@ -0,0 +1,98 @@
1
+ <script module lang="ts">
2
+ export type DateRangeFormat = 'date' | 'time' | 'datetime' | 'iso';
3
+ export type DateRangeValue =
4
+ | [string | number | Date | null | undefined, string | number | Date | null | undefined]
5
+ | { start?: string | number | Date | null; end?: string | number | Date | null; from?: string | number | Date | null; to?: string | number | Date | null }
6
+ | null
7
+ | undefined;
8
+ </script>
9
+
10
+ <script lang="ts">
11
+ import { Calendar } from '@lucide/svelte';
12
+ import { cn } from '../../utils.js';
13
+
14
+ interface Props {
15
+ value?: DateRangeValue;
16
+ startDate?: string | number | Date | null | undefined;
17
+ endDate?: string | number | Date | null | undefined;
18
+ separator?: string;
19
+ locale?: string;
20
+ format?: DateRangeFormat;
21
+ options?: Intl.DateTimeFormatOptions;
22
+ nullLabel?: string;
23
+ showIcon?: boolean;
24
+ class?: string;
25
+ }
26
+
27
+ let {
28
+ value,
29
+ startDate,
30
+ endDate,
31
+ separator = '~',
32
+ locale = 'en-US',
33
+ format = 'date',
34
+ options,
35
+ nullLabel = '—',
36
+ showIcon = false,
37
+ class: className = '',
38
+ }: Props = $props();
39
+
40
+ const start = $derived.by(() => {
41
+ if (startDate !== undefined) return startDate;
42
+ if (Array.isArray(value)) return value[0];
43
+ if (value && typeof value === 'object') {
44
+ const obj = value as Record<string, unknown>;
45
+ return (obj.start ?? obj.from) as string | number | Date | null | undefined;
46
+ }
47
+ return undefined;
48
+ });
49
+
50
+ const end = $derived.by(() => {
51
+ if (endDate !== undefined) return endDate;
52
+ if (Array.isArray(value)) return value[1];
53
+ if (value && typeof value === 'object') {
54
+ const obj = value as Record<string, unknown>;
55
+ return (obj.end ?? obj.to) as string | number | Date | null | undefined;
56
+ }
57
+ return undefined;
58
+ });
59
+
60
+ function formatDate(val: string | number | Date | null | undefined): string | null {
61
+ if (val == null || val === '') return null;
62
+ const d = val instanceof Date ? val : new Date(val);
63
+ if (isNaN(d.getTime())) return null;
64
+
65
+ if (format === 'iso') return d.toISOString();
66
+
67
+ const defaultOptions: Intl.DateTimeFormatOptions =
68
+ options ??
69
+ (format === 'datetime'
70
+ ? { dateStyle: 'medium', timeStyle: 'short' }
71
+ : format === 'time'
72
+ ? { timeStyle: 'medium' }
73
+ : { dateStyle: 'medium' });
74
+
75
+ try {
76
+ return new Intl.DateTimeFormat(locale, defaultOptions).format(d);
77
+ } catch {
78
+ return d.toLocaleDateString();
79
+ }
80
+ }
81
+
82
+ const startFormatted = $derived(formatDate(start));
83
+ const endFormatted = $derived(formatDate(end));
84
+
85
+ const display = $derived.by(() => {
86
+ if (!startFormatted && !endFormatted) return nullLabel;
87
+ if (startFormatted && endFormatted) return `${startFormatted} ${separator} ${endFormatted}`;
88
+ if (startFormatted) return `${startFormatted} ${separator} ${nullLabel}`;
89
+ return `${nullLabel} ${separator} ${endFormatted}`;
90
+ });
91
+ </script>
92
+
93
+ <span class={cn('field-date-range inline-flex items-center gap-1.5 text-sm', className)}>
94
+ {#if showIcon}
95
+ <Calendar class="h-3.5 w-3.5 text-muted-foreground shrink-0" />
96
+ {/if}
97
+ <span>{display}</span>
98
+ </span>
@@ -0,0 +1,22 @@
1
+ export type DateRangeFormat = 'date' | 'time' | 'datetime' | 'iso';
2
+ export type DateRangeValue = [string | number | Date | null | undefined, string | number | Date | null | undefined] | {
3
+ start?: string | number | Date | null;
4
+ end?: string | number | Date | null;
5
+ from?: string | number | Date | null;
6
+ to?: string | number | Date | null;
7
+ } | null | undefined;
8
+ interface Props {
9
+ value?: DateRangeValue;
10
+ startDate?: string | number | Date | null | undefined;
11
+ endDate?: string | number | Date | null | undefined;
12
+ separator?: string;
13
+ locale?: string;
14
+ format?: DateRangeFormat;
15
+ options?: Intl.DateTimeFormatOptions;
16
+ nullLabel?: string;
17
+ showIcon?: boolean;
18
+ class?: string;
19
+ }
20
+ declare const DateRangeField: import("svelte").Component<Props, {}, "">;
21
+ type DateRangeField = ReturnType<typeof DateRangeField>;
22
+ export default DateRangeField;
@@ -0,0 +1,103 @@
1
+ <script module lang="ts">
2
+ export type PercentTone = 'auto' | 'success' | 'warning' | 'destructive' | 'info' | 'neutral';
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ import { cn } from '../../utils.js';
7
+
8
+ interface Props {
9
+ value?: number | string | null | undefined;
10
+ precision?: number;
11
+ scale?: '100' | '1';
12
+ showProgress?: boolean;
13
+ tone?: PercentTone;
14
+ nullLabel?: string;
15
+ class?: string;
16
+ }
17
+
18
+ let {
19
+ value,
20
+ precision = 1,
21
+ scale = '100',
22
+ showProgress = false,
23
+ tone = 'neutral',
24
+ nullLabel = '—',
25
+ class: className = '',
26
+ }: Props = $props();
27
+
28
+ const normalizedPrecision = $derived(
29
+ Math.min(20, Math.max(0, Math.trunc(Number.isFinite(precision) ? precision : 1)))
30
+ );
31
+
32
+ const numericValue = $derived.by(() => {
33
+ if (value == null) return null;
34
+ if (typeof value === 'string' && value.trim() === '') return null;
35
+ const parsed = typeof value === 'number' ? value : Number(value);
36
+ return Number.isFinite(parsed) ? parsed : null;
37
+ });
38
+
39
+ const percentNumber = $derived.by(() => {
40
+ if (numericValue === null) return null;
41
+ const scaled = scale === '1' ? numericValue * 100 : numericValue;
42
+ return Number.isFinite(scaled) ? scaled : null;
43
+ });
44
+
45
+ const formatted = $derived(
46
+ percentNumber === null ? nullLabel : `${percentNumber.toFixed(normalizedPrecision)}%`
47
+ );
48
+
49
+ const resolvedTone = $derived.by((): PercentTone => {
50
+ if (tone !== 'auto') return tone;
51
+ if (percentNumber === null) return 'neutral';
52
+ if (percentNumber >= 80) return 'success';
53
+ if (percentNumber >= 50) return 'warning';
54
+ return 'destructive';
55
+ });
56
+
57
+ const toneTextClass: Record<PercentTone, string> = {
58
+ auto: '',
59
+ success: 'text-success font-medium',
60
+ warning: 'text-warning-foreground font-medium',
61
+ destructive: 'text-destructive font-medium',
62
+ info: 'text-primary font-medium',
63
+ neutral: 'text-foreground',
64
+ };
65
+
66
+ const toneBarClass: Record<PercentTone, string> = {
67
+ auto: '',
68
+ success: 'bg-success',
69
+ warning: 'bg-warning',
70
+ destructive: 'bg-destructive',
71
+ info: 'bg-primary',
72
+ neutral: 'bg-primary',
73
+ };
74
+
75
+ const clampedProgress = $derived(
76
+ percentNumber === null ? 0 : Math.min(100, Math.max(0, percentNumber))
77
+ );
78
+ </script>
79
+
80
+ {#if percentNumber === null}
81
+ <span class={cn('field-percent text-muted-foreground text-sm', className)}>{nullLabel}</span>
82
+ {:else}
83
+ <div class={cn('field-percent inline-flex items-center gap-2 text-sm', className)}>
84
+ <span class={cn('tabular-nums', toneTextClass[resolvedTone])}>
85
+ {formatted}
86
+ </span>
87
+ {#if showProgress}
88
+ <div
89
+ class="h-1.5 w-16 overflow-hidden rounded-full bg-muted"
90
+ role="progressbar"
91
+ aria-label="Percentage"
92
+ aria-valuemin="0"
93
+ aria-valuemax="100"
94
+ aria-valuenow={clampedProgress}
95
+ >
96
+ <div
97
+ class={cn('h-full transition-all duration-300', toneBarClass[resolvedTone])}
98
+ style="width: {clampedProgress}%"
99
+ ></div>
100
+ </div>
101
+ {/if}
102
+ </div>
103
+ {/if}
@@ -0,0 +1,13 @@
1
+ export type PercentTone = 'auto' | 'success' | 'warning' | 'destructive' | 'info' | 'neutral';
2
+ interface Props {
3
+ value?: number | string | null | undefined;
4
+ precision?: number;
5
+ scale?: '100' | '1';
6
+ showProgress?: boolean;
7
+ tone?: PercentTone;
8
+ nullLabel?: string;
9
+ class?: string;
10
+ }
11
+ declare const PercentField: import("svelte").Component<Props, {}, "">;
12
+ type PercentField = ReturnType<typeof PercentField>;
13
+ export default PercentField;
@@ -0,0 +1,86 @@
1
+ <script module lang="ts">
2
+ export type RatingSize = 'sm' | 'default' | 'lg';
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ import { Star, StarHalf } from '@lucide/svelte';
7
+ import { cn } from '../../utils.js';
8
+
9
+ interface Props {
10
+ value?: number | string | null | undefined;
11
+ max?: number;
12
+ showValue?: boolean;
13
+ size?: RatingSize;
14
+ nullLabel?: string;
15
+ class?: string;
16
+ }
17
+
18
+ let {
19
+ value,
20
+ max = 5,
21
+ showValue = false,
22
+ size = 'default',
23
+ nullLabel = '—',
24
+ class: className = '',
25
+ }: Props = $props();
26
+
27
+ const normalizedMax = $derived(
28
+ Number.isFinite(max) ? Math.min(100, Math.max(1, Math.trunc(max))) : 5
29
+ );
30
+
31
+ const numericValue = $derived.by(() => {
32
+ if (value == null) return null;
33
+ if (typeof value === 'string' && value.trim() === '') return null;
34
+ const parsed = typeof value === 'number' ? value : Number(value);
35
+ if (!Number.isFinite(parsed)) return null;
36
+ return Math.max(0, Math.min(normalizedMax, parsed));
37
+ });
38
+
39
+ const sizeClasses: Record<RatingSize, { icon: string; text: string }> = {
40
+ sm: { icon: 'h-3.5 w-3.5', text: 'text-xs' },
41
+ default: { icon: 'h-4 w-4', text: 'text-sm' },
42
+ lg: { icon: 'h-5 w-5', text: 'text-base' },
43
+ };
44
+
45
+ const stars = $derived.by(() => {
46
+ if (numericValue === null) return [];
47
+ const result: ('full' | 'half' | 'empty')[] = [];
48
+ for (let index = 1; index <= normalizedMax; index += 1) {
49
+ if (numericValue >= index) {
50
+ result.push('full');
51
+ } else if (numericValue >= index - 0.5) {
52
+ result.push('half');
53
+ } else {
54
+ result.push('empty');
55
+ }
56
+ }
57
+ return result;
58
+ });
59
+ </script>
60
+
61
+ {#if numericValue === null}
62
+ <span class={cn('field-rating text-muted-foreground text-sm', className)}>{nullLabel}</span>
63
+ {:else}
64
+ <div class={cn('field-rating inline-flex items-center gap-1.5', className)}>
65
+ <div
66
+ class="inline-flex items-center gap-0.5 text-amber-500"
67
+ role="img"
68
+ aria-label={`${numericValue} out of ${normalizedMax}`}
69
+ >
70
+ {#each stars as starType, idx (idx)}
71
+ {#if starType === 'full'}
72
+ <Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
73
+ {:else if starType === 'half'}
74
+ <StarHalf aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
75
+ {:else}
76
+ <Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'text-muted-foreground/30')} />
77
+ {/if}
78
+ {/each}
79
+ </div>
80
+ {#if showValue}
81
+ <span class={cn('tabular-nums font-medium text-foreground', sizeClasses[size].text)}>
82
+ {numericValue}
83
+ </span>
84
+ {/if}
85
+ </div>
86
+ {/if}
@@ -0,0 +1,12 @@
1
+ export type RatingSize = 'sm' | 'default' | 'lg';
2
+ interface Props {
3
+ value?: number | string | null | undefined;
4
+ max?: number;
5
+ showValue?: boolean;
6
+ size?: RatingSize;
7
+ nullLabel?: string;
8
+ class?: string;
9
+ }
10
+ declare const RatingField: import("svelte").Component<Props, {}, "">;
11
+ type RatingField = ReturnType<typeof RatingField>;
12
+ export default RatingField;
package/dist/index.d.ts CHANGED
@@ -103,8 +103,14 @@ export { default as ImageField } from './components/fields/ImageField.svelte';
103
103
  export { default as SelectField } from './components/fields/SelectField.svelte';
104
104
  export { default as MultiSelectField } from './components/fields/MultiSelectField.svelte';
105
105
  export { default as RelationField } from './components/fields/RelationField.svelte';
106
+ export { default as DateRangeField } from './components/fields/DateRangeField.svelte';
106
107
  export { default as JsonField } from './components/fields/JsonField.svelte';
107
108
  export { default as RichTextField } from './components/fields/RichTextField.svelte';
109
+ export { default as CopyField } from './components/fields/CopyField.svelte';
110
+ export { default as AvatarField } from './components/fields/AvatarField.svelte';
111
+ export { default as CodeField } from './components/fields/CodeField.svelte';
112
+ export { default as PercentField } from './components/fields/PercentField.svelte';
113
+ export { default as RatingField } from './components/fields/RatingField.svelte';
108
114
  export { CreateButton, EditButton, DeleteButton, ShowButton, ListButton, RefreshButton, ExportButton, ImportButton, SaveButton, CloneButton, } from './components/buttons/index.js';
109
115
  export { Button, buttonVariants } from './components/ui/button/index.js';
110
116
  export { Input } from './components/ui/input/index.js';
@@ -209,3 +215,9 @@ export type { TimelineItem } from './components/content/AuditTimeline.svelte';
209
215
  export type { TagTone } from './components/fields/TagField.svelte';
210
216
  export type { BooleanTone } from './components/fields/BooleanField.svelte';
211
217
  export type { DateFieldFormat } from './components/fields/DateField.svelte';
218
+ export type { AvatarStatus, AvatarSize } from './components/fields/AvatarField.svelte';
219
+ export { default as FilterDropdown } from './components/FilterDropdown.svelte';
220
+ export { default as BatchActionBar } from './components/BatchActionBar.svelte';
221
+ export type { DateRangeFormat, DateRangeValue } from './components/fields/DateRangeField.svelte';
222
+ export type { PercentTone } from './components/fields/PercentField.svelte';
223
+ export type { RatingSize } from './components/fields/RatingField.svelte';
package/dist/index.js CHANGED
@@ -106,8 +106,14 @@ export { default as ImageField } from './components/fields/ImageField.svelte';
106
106
  export { default as SelectField } from './components/fields/SelectField.svelte';
107
107
  export { default as MultiSelectField } from './components/fields/MultiSelectField.svelte';
108
108
  export { default as RelationField } from './components/fields/RelationField.svelte';
109
+ export { default as DateRangeField } from './components/fields/DateRangeField.svelte';
109
110
  export { default as JsonField } from './components/fields/JsonField.svelte';
110
111
  export { default as RichTextField } from './components/fields/RichTextField.svelte';
112
+ export { default as CopyField } from './components/fields/CopyField.svelte';
113
+ export { default as AvatarField } from './components/fields/AvatarField.svelte';
114
+ export { default as CodeField } from './components/fields/CodeField.svelte';
115
+ export { default as PercentField } from './components/fields/PercentField.svelte';
116
+ export { default as RatingField } from './components/fields/RatingField.svelte';
111
117
  // Rich text editor (requires @svadmin/editor) as a direct import in consumer app
112
118
  // CRUD Buttons
113
119
  export { CreateButton, EditButton, DeleteButton, ShowButton, ListButton, RefreshButton, ExportButton, ImportButton, SaveButton, CloneButton, } from './components/buttons/index.js';
@@ -203,3 +209,5 @@ export { referenceDemoData } from './reference-data.js';
203
209
  export { default as RowActions } from './components/RowActions.svelte';
204
210
  export { default as DetailDrawer } from './components/DetailDrawer.svelte';
205
211
  export { default as AuditTimeline } from './components/content/AuditTimeline.svelte';
212
+ export { default as FilterDropdown } from './components/FilterDropdown.svelte';
213
+ export { default as BatchActionBar } from './components/BatchActionBar.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.52.0",
3
+ "version": "0.53.1",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [