@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.
- package/dist/components/BatchActionBar.svelte +119 -0
- package/dist/components/BatchActionBar.svelte.d.ts +15 -0
- package/dist/components/FilterDropdown.svelte +252 -0
- package/dist/components/FilterDropdown.svelte.d.ts +24 -0
- package/dist/components/fields/AvatarField.svelte +123 -0
- package/dist/components/fields/AvatarField.svelte.d.ts +16 -0
- package/dist/components/fields/CodeField.svelte +108 -0
- package/dist/components/fields/CodeField.svelte.d.ts +13 -0
- package/dist/components/fields/CopyField.svelte +102 -0
- package/dist/components/fields/CopyField.svelte.d.ts +15 -0
- package/dist/components/fields/DateRangeField.svelte +98 -0
- package/dist/components/fields/DateRangeField.svelte.d.ts +22 -0
- package/dist/components/fields/PercentField.svelte +103 -0
- package/dist/components/fields/PercentField.svelte.d.ts +13 -0
- package/dist/components/fields/RatingField.svelte +86 -0
- package/dist/components/fields/RatingField.svelte.d.ts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +8 -0
- 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,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';
|