@svadmin/ui 0.53.0 → 0.55.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.
- package/dist/components/IntegrationsSettings.svelte +32 -9
- package/dist/components/IntegrationsSettings.svelte.d.ts +12 -1
- package/dist/components/content/DescriptionList.svelte +2 -2
- package/dist/components/fieldComponentMap.js +19 -0
- package/dist/components/fields/CodeField.svelte +17 -5
- package/dist/components/fields/CurrencyField.svelte +103 -0
- package/dist/components/fields/CurrencyField.svelte.d.ts +17 -0
- package/dist/components/fields/PercentField.svelte +26 -15
- package/dist/components/fields/PhoneField.svelte +110 -0
- package/dist/components/fields/PhoneField.svelte.d.ts +13 -0
- package/dist/components/fields/RatingField.svelte +20 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +2 -2
|
@@ -8,24 +8,47 @@
|
|
|
8
8
|
import StatusBadge from './content/StatusBadge.svelte';
|
|
9
9
|
|
|
10
10
|
const i18n = useTranslation();
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
|
|
12
|
+
interface Integration {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
connected?: boolean;
|
|
17
|
+
Icon: Component;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
integrations?: Integration[];
|
|
22
|
+
onConnectionChange?: (id: string, connected: boolean) => void | Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let { integrations, onConnectionChange }: Props = $props();
|
|
26
|
+
const defaultIntegrations = $derived<Integration[]>([
|
|
27
|
+
{ id: 'source-control', name: i18n.t('integrations.sourceControl'), description: i18n.t('integrations.sourceControlDescription'), Icon: FolderGit },
|
|
28
|
+
{ id: 'chatops', name: 'ChatOps', description: i18n.t('integrations.chatOpsDescription'), Icon: MessageCircle },
|
|
29
|
+
{ id: 'identity-cloud', name: i18n.t('integrations.identityCloud'), description: i18n.t('integrations.identityCloudDescription'), Icon: Cloud },
|
|
30
|
+
{ id: 'webhooks', name: 'Webhooks', description: i18n.t('integrations.webhooksDescription'), Icon: Plug },
|
|
17
31
|
]);
|
|
18
|
-
|
|
32
|
+
const displayedIntegrations = $derived(integrations ?? defaultIntegrations);
|
|
19
33
|
</script>
|
|
20
34
|
|
|
21
35
|
<div class="space-y-6">
|
|
22
36
|
<div><h2 class="text-xl font-semibold text-foreground">{i18n.t('settings.integrations')}</h2><p class="mt-1 text-sm text-muted-foreground">{i18n.t('settings.integrationsDescription')}</p></div>
|
|
23
37
|
<SettingsGroup title={i18n.t('settings.integrations')} description={i18n.t('integrations.addMoreHint')}>
|
|
24
38
|
<div class="divide-y divide-border">
|
|
25
|
-
{#each
|
|
39
|
+
{#each displayedIntegrations as integration (integration.id)}
|
|
26
40
|
<SettingsFieldRow label={integration.name} description={integration.description}>
|
|
27
41
|
{#snippet control()}
|
|
28
|
-
<div class="flex items-center gap-3"
|
|
42
|
+
<div class="flex items-center gap-3">
|
|
43
|
+
{#if integration.connected !== undefined}
|
|
44
|
+
<StatusBadge status={integration.connected ? 'success' : 'neutral'} label={integration.connected ? i18n.t('integrations.statusConnected') : i18n.t('integrations.statusDisconnected')} />
|
|
45
|
+
{#if onConnectionChange}
|
|
46
|
+
<Switch checked={integration.connected} onCheckedChange={(checked) => onConnectionChange?.(integration.id, checked)} aria-label={integration.name} />
|
|
47
|
+
{/if}
|
|
48
|
+
{:else}
|
|
49
|
+
<span class="text-xs text-muted-foreground">{i18n.t('integrations.statusProvidedByHost')}</span>
|
|
50
|
+
{/if}
|
|
51
|
+
</div>
|
|
29
52
|
{/snippet}
|
|
30
53
|
</SettingsFieldRow>
|
|
31
54
|
{/each}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { Component } from 'svelte';
|
|
2
|
-
|
|
2
|
+
interface Integration {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
connected?: boolean;
|
|
7
|
+
Icon: Component;
|
|
8
|
+
}
|
|
9
|
+
interface Props {
|
|
10
|
+
integrations?: Integration[];
|
|
11
|
+
onConnectionChange?: (id: string, connected: boolean) => void | Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
declare const IntegrationsSettings: Component<Props, {}, "">;
|
|
3
14
|
type IntegrationsSettings = ReturnType<typeof IntegrationsSettings>;
|
|
4
15
|
export default IntegrationsSettings;
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
{#if bordered}
|
|
55
55
|
<div class={cn('overflow-hidden rounded-lg border border-border bg-card text-foreground', className)}>
|
|
56
56
|
<dl class={cn('grid divide-y divide-border/60 sm:divide-y-0', columnClass)}>
|
|
57
|
-
{#each items as item (item.label)}
|
|
57
|
+
{#each items as item, idx (item.label ? `${item.label}-${idx}` : idx)}
|
|
58
58
|
<div
|
|
59
59
|
class={cn(
|
|
60
60
|
'flex flex-col border-b border-r border-border/40 last:border-b-0',
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
className,
|
|
100
100
|
)}
|
|
101
101
|
>
|
|
102
|
-
{#each items as item (item.label)}
|
|
102
|
+
{#each items as item, idx (item.label ? `${item.label}-${idx}` : idx)}
|
|
103
103
|
<div
|
|
104
104
|
class={cn(
|
|
105
105
|
'min-w-0',
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// Used by both ShowPage and FieldRenderer to avoid duplicate if/else chains.
|
|
4
4
|
// Components are lazily resolved by field type at runtime.
|
|
5
5
|
// Pre-import all field display components (tree-shaken by bundler)
|
|
6
|
+
import TextField from './fields/TextField.svelte';
|
|
6
7
|
import BooleanField from './fields/BooleanField.svelte';
|
|
7
8
|
import ImageField from './fields/ImageField.svelte';
|
|
8
9
|
import TagField from './fields/TagField.svelte';
|
|
9
10
|
import DateField from './fields/DateField.svelte';
|
|
11
|
+
import DateRangeField from './fields/DateRangeField.svelte';
|
|
10
12
|
import EmailField from './fields/EmailField.svelte';
|
|
11
13
|
import UrlField from './fields/UrlField.svelte';
|
|
12
14
|
import SelectField from './fields/SelectField.svelte';
|
|
@@ -15,17 +17,27 @@ import RelationField from './fields/RelationField.svelte';
|
|
|
15
17
|
import JsonField from './fields/JsonField.svelte';
|
|
16
18
|
import RichTextField from './fields/RichTextField.svelte';
|
|
17
19
|
import NumberField from './fields/NumberField.svelte';
|
|
20
|
+
import CurrencyField from './fields/CurrencyField.svelte';
|
|
21
|
+
import PhoneField from './fields/PhoneField.svelte';
|
|
22
|
+
import PercentField from './fields/PercentField.svelte';
|
|
23
|
+
import RatingField from './fields/RatingField.svelte';
|
|
24
|
+
import CodeField from './fields/CodeField.svelte';
|
|
25
|
+
import AvatarField from './fields/AvatarField.svelte';
|
|
26
|
+
import CopyField from './fields/CopyField.svelte';
|
|
18
27
|
import FileField from './fields/FileField.svelte';
|
|
19
28
|
/**
|
|
20
29
|
* Built-in display components for show/detail pages.
|
|
21
30
|
* Keys match FieldDefinition.type values.
|
|
22
31
|
*/
|
|
23
32
|
export const builtinDisplayComponents = {
|
|
33
|
+
text: TextField,
|
|
34
|
+
string: TextField,
|
|
24
35
|
boolean: BooleanField,
|
|
25
36
|
image: ImageField,
|
|
26
37
|
images: ImageField,
|
|
27
38
|
tags: TagField,
|
|
28
39
|
date: DateField,
|
|
40
|
+
daterange: DateRangeField,
|
|
29
41
|
email: EmailField,
|
|
30
42
|
url: UrlField,
|
|
31
43
|
select: SelectField,
|
|
@@ -35,6 +47,13 @@ export const builtinDisplayComponents = {
|
|
|
35
47
|
richtext: RichTextField,
|
|
36
48
|
textarea: RichTextField,
|
|
37
49
|
number: NumberField,
|
|
50
|
+
currency: CurrencyField,
|
|
51
|
+
phone: PhoneField,
|
|
52
|
+
percent: PercentField,
|
|
53
|
+
rating: RatingField,
|
|
54
|
+
code: CodeField,
|
|
55
|
+
avatar: AvatarField,
|
|
56
|
+
copy: CopyField,
|
|
38
57
|
file: FileField,
|
|
39
58
|
};
|
|
40
59
|
// ─── Custom component registry ─────────────────────────────────
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
2
3
|
import { Copy, Check } from '@lucide/svelte';
|
|
3
4
|
import { Badge } from '../ui/badge/index.js';
|
|
4
5
|
import { cn } from '../../utils.js';
|
|
@@ -27,6 +28,12 @@
|
|
|
27
28
|
|
|
28
29
|
let copied = $state(false);
|
|
29
30
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
31
|
+
let destroyed = false;
|
|
32
|
+
|
|
33
|
+
onDestroy(() => {
|
|
34
|
+
destroyed = true;
|
|
35
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
36
|
+
});
|
|
30
37
|
|
|
31
38
|
const formattedCode = $derived.by(() => {
|
|
32
39
|
if (value == null || value === '') return '';
|
|
@@ -39,20 +46,25 @@
|
|
|
39
46
|
}
|
|
40
47
|
});
|
|
41
48
|
|
|
42
|
-
async function handleCopy() {
|
|
49
|
+
async function handleCopy(event: MouseEvent) {
|
|
50
|
+
event.stopPropagation();
|
|
43
51
|
if (!formattedCode || !copyable) return;
|
|
52
|
+
|
|
53
|
+
const writeText = globalThis.navigator?.clipboard?.writeText;
|
|
54
|
+
if (!writeText) return;
|
|
55
|
+
|
|
44
56
|
try {
|
|
45
|
-
|
|
46
|
-
await navigator.clipboard.writeText(formattedCode);
|
|
47
|
-
}
|
|
57
|
+
await writeText.call(globalThis.navigator.clipboard, formattedCode);
|
|
48
58
|
} catch {
|
|
49
|
-
|
|
59
|
+
return;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
if (destroyed) return;
|
|
52
63
|
copied = true;
|
|
53
64
|
if (timeoutId) clearTimeout(timeoutId);
|
|
54
65
|
timeoutId = setTimeout(() => {
|
|
55
66
|
copied = false;
|
|
67
|
+
timeoutId = null;
|
|
56
68
|
}, 1500);
|
|
57
69
|
}
|
|
58
70
|
</script>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export type CurrencyTone = 'auto' | 'success' | 'warning' | 'destructive' | '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
|
+
currency?: string;
|
|
11
|
+
locale?: string;
|
|
12
|
+
precision?: number;
|
|
13
|
+
compact?: boolean;
|
|
14
|
+
symbol?: string;
|
|
15
|
+
colored?: boolean;
|
|
16
|
+
tone?: CurrencyTone;
|
|
17
|
+
nullLabel?: string;
|
|
18
|
+
tabular?: boolean;
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
value,
|
|
24
|
+
currency = 'USD',
|
|
25
|
+
locale = 'en-US',
|
|
26
|
+
precision = 2,
|
|
27
|
+
compact = false,
|
|
28
|
+
symbol,
|
|
29
|
+
colored = false,
|
|
30
|
+
tone = 'neutral',
|
|
31
|
+
nullLabel = '—',
|
|
32
|
+
tabular = true,
|
|
33
|
+
class: className = '',
|
|
34
|
+
}: Props = $props();
|
|
35
|
+
|
|
36
|
+
const normalizedPrecision = $derived(
|
|
37
|
+
Number.isFinite(precision) ? Math.min(20, Math.max(0, Math.trunc(precision))) : 2
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const numericValue = $derived.by(() => {
|
|
41
|
+
if (value == null) return null;
|
|
42
|
+
if (typeof value === 'string' && value.trim() === '') return null;
|
|
43
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
44
|
+
return Number.isFinite(num) ? num : null;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const formatted = $derived.by(() => {
|
|
48
|
+
if (numericValue === null) return nullLabel;
|
|
49
|
+
|
|
50
|
+
const opts: Intl.NumberFormatOptions = {
|
|
51
|
+
style: symbol ? 'decimal' : 'currency',
|
|
52
|
+
currency: symbol ? undefined : currency,
|
|
53
|
+
minimumFractionDigits: normalizedPrecision,
|
|
54
|
+
maximumFractionDigits: normalizedPrecision,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (compact) {
|
|
58
|
+
opts.notation = 'compact';
|
|
59
|
+
opts.compactDisplay = 'short';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
let res = new Intl.NumberFormat(locale, opts).format(numericValue);
|
|
64
|
+
if (symbol) {
|
|
65
|
+
res = `${symbol}${res}`;
|
|
66
|
+
}
|
|
67
|
+
return res;
|
|
68
|
+
} catch {
|
|
69
|
+
return String(numericValue);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const resolvedTone = $derived.by(() => {
|
|
74
|
+
if (tone !== 'auto' && tone !== 'neutral') return tone;
|
|
75
|
+
if ((!colored && tone !== 'auto') || numericValue === null) return 'neutral';
|
|
76
|
+
if (numericValue > 0) return 'success';
|
|
77
|
+
if (numericValue < 0) return 'destructive';
|
|
78
|
+
return 'neutral';
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const toneClasses: Record<CurrencyTone, string> = {
|
|
82
|
+
auto: '',
|
|
83
|
+
success: 'text-success font-medium',
|
|
84
|
+
warning: 'text-warning-foreground font-medium',
|
|
85
|
+
destructive: 'text-destructive font-medium',
|
|
86
|
+
neutral: 'text-foreground',
|
|
87
|
+
};
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
{#if numericValue === null}
|
|
91
|
+
<span class={cn('field-currency text-muted-foreground text-sm', className)}>{nullLabel}</span>
|
|
92
|
+
{:else}
|
|
93
|
+
<span
|
|
94
|
+
class={cn(
|
|
95
|
+
'field-currency inline-flex items-center text-sm',
|
|
96
|
+
tabular && 'font-mono tabular-nums',
|
|
97
|
+
toneClasses[resolvedTone],
|
|
98
|
+
className
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
{formatted}
|
|
102
|
+
</span>
|
|
103
|
+
{/if}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type CurrencyTone = 'auto' | 'success' | 'warning' | 'destructive' | 'neutral';
|
|
2
|
+
interface Props {
|
|
3
|
+
value?: number | string | null | undefined;
|
|
4
|
+
currency?: string;
|
|
5
|
+
locale?: string;
|
|
6
|
+
precision?: number;
|
|
7
|
+
compact?: boolean;
|
|
8
|
+
symbol?: string;
|
|
9
|
+
colored?: boolean;
|
|
10
|
+
tone?: CurrencyTone;
|
|
11
|
+
nullLabel?: string;
|
|
12
|
+
tabular?: boolean;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
declare const CurrencyField: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type CurrencyField = ReturnType<typeof CurrencyField>;
|
|
17
|
+
export default CurrencyField;
|
|
@@ -25,23 +25,28 @@
|
|
|
25
25
|
class: className = '',
|
|
26
26
|
}: Props = $props();
|
|
27
27
|
|
|
28
|
+
const normalizedPrecision = $derived(
|
|
29
|
+
Math.min(20, Math.max(0, Math.trunc(Number.isFinite(precision) ? precision : 1)))
|
|
30
|
+
);
|
|
31
|
+
|
|
28
32
|
const numericValue = $derived.by(() => {
|
|
29
|
-
if (value == null
|
|
30
|
-
|
|
31
|
-
|
|
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;
|
|
32
37
|
});
|
|
33
38
|
|
|
34
39
|
const percentNumber = $derived.by(() => {
|
|
35
40
|
if (numericValue === null) return null;
|
|
36
|
-
|
|
41
|
+
const scaled = scale === '1' ? numericValue * 100 : numericValue;
|
|
42
|
+
return Number.isFinite(scaled) ? scaled : null;
|
|
37
43
|
});
|
|
38
44
|
|
|
39
|
-
const formatted = $derived
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
});
|
|
45
|
+
const formatted = $derived(
|
|
46
|
+
percentNumber === null ? nullLabel : `${percentNumber.toFixed(normalizedPrecision)}%`
|
|
47
|
+
);
|
|
43
48
|
|
|
44
|
-
const resolvedTone = $derived.by(() => {
|
|
49
|
+
const resolvedTone = $derived.by((): PercentTone => {
|
|
45
50
|
if (tone !== 'auto') return tone;
|
|
46
51
|
if (percentNumber === null) return 'neutral';
|
|
47
52
|
if (percentNumber >= 80) return 'success';
|
|
@@ -67,13 +72,12 @@
|
|
|
67
72
|
neutral: 'bg-primary',
|
|
68
73
|
};
|
|
69
74
|
|
|
70
|
-
const clampedProgress = $derived
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
75
|
+
const clampedProgress = $derived(
|
|
76
|
+
percentNumber === null ? 0 : Math.min(100, Math.max(0, percentNumber))
|
|
77
|
+
);
|
|
74
78
|
</script>
|
|
75
79
|
|
|
76
|
-
{#if
|
|
80
|
+
{#if percentNumber === null}
|
|
77
81
|
<span class={cn('field-percent text-muted-foreground text-sm', className)}>{nullLabel}</span>
|
|
78
82
|
{:else}
|
|
79
83
|
<div class={cn('field-percent inline-flex items-center gap-2 text-sm', className)}>
|
|
@@ -81,7 +85,14 @@
|
|
|
81
85
|
{formatted}
|
|
82
86
|
</span>
|
|
83
87
|
{#if showProgress}
|
|
84
|
-
<div
|
|
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
|
+
>
|
|
85
96
|
<div
|
|
86
97
|
class={cn('h-full transition-all duration-300', toneBarClass[resolvedTone])}
|
|
87
98
|
style="width: {clampedProgress}%"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
import { Phone, Copy, Check } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '../../utils.js';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
value?: string | number | null | undefined;
|
|
8
|
+
href?: string;
|
|
9
|
+
showIcon?: boolean;
|
|
10
|
+
clickable?: boolean;
|
|
11
|
+
copyable?: boolean;
|
|
12
|
+
nullLabel?: string;
|
|
13
|
+
oncopy?: (value: string) => void;
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
value,
|
|
19
|
+
href,
|
|
20
|
+
showIcon = true,
|
|
21
|
+
clickable = true,
|
|
22
|
+
copyable = false,
|
|
23
|
+
nullLabel = '—',
|
|
24
|
+
oncopy,
|
|
25
|
+
class: className = '',
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
let copied = $state(false);
|
|
29
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
let destroyed = false;
|
|
31
|
+
|
|
32
|
+
onDestroy(() => {
|
|
33
|
+
destroyed = true;
|
|
34
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const stringValue = $derived.by(() => {
|
|
38
|
+
if (value == null || value === '') return null;
|
|
39
|
+
const str = String(value).trim();
|
|
40
|
+
return str.length > 0 ? str : null;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const telHref = $derived.by(() => {
|
|
44
|
+
if (!stringValue) return undefined;
|
|
45
|
+
const candidate = (href ?? stringValue).replace(/^tel:/i, '');
|
|
46
|
+
const normalized = candidate.replace(/[\s().-]/g, '');
|
|
47
|
+
if (!/^\+?\d+$/.test(normalized)) return undefined;
|
|
48
|
+
return `tel:${normalized}`;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function handleCopy(e: MouseEvent) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
e.stopPropagation();
|
|
54
|
+
if (!stringValue || !copyable) return;
|
|
55
|
+
|
|
56
|
+
const writeText = globalThis.navigator?.clipboard?.writeText;
|
|
57
|
+
if (!writeText) return;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await writeText.call(globalThis.navigator.clipboard, stringValue);
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (destroyed) return;
|
|
66
|
+
copied = true;
|
|
67
|
+
oncopy?.(stringValue);
|
|
68
|
+
|
|
69
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
70
|
+
timeoutId = setTimeout(() => {
|
|
71
|
+
copied = false;
|
|
72
|
+
timeoutId = null;
|
|
73
|
+
}, 2000);
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
{#if !stringValue}
|
|
78
|
+
<span class={cn('field-phone text-muted-foreground text-sm', className)}>{nullLabel}</span>
|
|
79
|
+
{:else}
|
|
80
|
+
<div class={cn('field-phone inline-flex items-center gap-1.5 text-sm', className)}>
|
|
81
|
+
{#if showIcon}
|
|
82
|
+
<Phone class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
83
|
+
{/if}
|
|
84
|
+
{#if clickable && telHref}
|
|
85
|
+
<a
|
|
86
|
+
href={telHref}
|
|
87
|
+
class="font-medium text-foreground hover:text-primary transition-colors underline-offset-4 hover:underline"
|
|
88
|
+
>
|
|
89
|
+
{stringValue}
|
|
90
|
+
</a>
|
|
91
|
+
{:else}
|
|
92
|
+
<span class="text-foreground">{stringValue}</span>
|
|
93
|
+
{/if}
|
|
94
|
+
{#if copyable}
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onclick={handleCopy}
|
|
98
|
+
class="inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
|
99
|
+
aria-label={copied ? 'Copied' : 'Copy phone number'}
|
|
100
|
+
title={copied ? 'Copied' : 'Copy phone number'}
|
|
101
|
+
>
|
|
102
|
+
{#if copied}
|
|
103
|
+
<Check class="h-3 w-3 text-success" />
|
|
104
|
+
{:else}
|
|
105
|
+
<Copy class="h-3 w-3" />
|
|
106
|
+
{/if}
|
|
107
|
+
</button>
|
|
108
|
+
{/if}
|
|
109
|
+
</div>
|
|
110
|
+
{/if}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
value?: string | number | null | undefined;
|
|
3
|
+
href?: string;
|
|
4
|
+
showIcon?: boolean;
|
|
5
|
+
clickable?: boolean;
|
|
6
|
+
copyable?: boolean;
|
|
7
|
+
nullLabel?: string;
|
|
8
|
+
oncopy?: (value: string) => void;
|
|
9
|
+
class?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const PhoneField: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type PhoneField = ReturnType<typeof PhoneField>;
|
|
13
|
+
export default PhoneField;
|
|
@@ -24,10 +24,16 @@
|
|
|
24
24
|
class: className = '',
|
|
25
25
|
}: Props = $props();
|
|
26
26
|
|
|
27
|
+
const normalizedMax = $derived(
|
|
28
|
+
Number.isFinite(max) ? Math.min(100, Math.max(1, Math.trunc(max))) : 5
|
|
29
|
+
);
|
|
30
|
+
|
|
27
31
|
const numericValue = $derived.by(() => {
|
|
28
|
-
if (value == null
|
|
29
|
-
|
|
30
|
-
|
|
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));
|
|
31
37
|
});
|
|
32
38
|
|
|
33
39
|
const sizeClasses: Record<RatingSize, { icon: string; text: string }> = {
|
|
@@ -39,10 +45,10 @@
|
|
|
39
45
|
const stars = $derived.by(() => {
|
|
40
46
|
if (numericValue === null) return [];
|
|
41
47
|
const result: ('full' | 'half' | 'empty')[] = [];
|
|
42
|
-
for (let
|
|
43
|
-
if (numericValue >=
|
|
48
|
+
for (let index = 1; index <= normalizedMax; index += 1) {
|
|
49
|
+
if (numericValue >= index) {
|
|
44
50
|
result.push('full');
|
|
45
|
-
} else if (numericValue >=
|
|
51
|
+
} else if (numericValue >= index - 0.5) {
|
|
46
52
|
result.push('half');
|
|
47
53
|
} else {
|
|
48
54
|
result.push('empty');
|
|
@@ -56,14 +62,18 @@
|
|
|
56
62
|
<span class={cn('field-rating text-muted-foreground text-sm', className)}>{nullLabel}</span>
|
|
57
63
|
{:else}
|
|
58
64
|
<div class={cn('field-rating inline-flex items-center gap-1.5', className)}>
|
|
59
|
-
<div
|
|
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
|
+
>
|
|
60
70
|
{#each stars as starType, idx (idx)}
|
|
61
71
|
{#if starType === 'full'}
|
|
62
|
-
<Star class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
|
|
72
|
+
<Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
|
|
63
73
|
{:else if starType === 'half'}
|
|
64
|
-
<StarHalf class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
|
|
74
|
+
<StarHalf aria-hidden="true" class={cn(sizeClasses[size].icon, 'fill-amber-500 text-amber-500')} />
|
|
65
75
|
{:else}
|
|
66
|
-
<Star class={cn(sizeClasses[size].icon, 'text-muted-foreground/30')} />
|
|
76
|
+
<Star aria-hidden="true" class={cn(sizeClasses[size].icon, 'text-muted-foreground/30')} />
|
|
67
77
|
{/if}
|
|
68
78
|
{/each}
|
|
69
79
|
</div>
|
package/dist/index.d.ts
CHANGED
|
@@ -111,6 +111,8 @@ export { default as AvatarField } from './components/fields/AvatarField.svelte';
|
|
|
111
111
|
export { default as CodeField } from './components/fields/CodeField.svelte';
|
|
112
112
|
export { default as PercentField } from './components/fields/PercentField.svelte';
|
|
113
113
|
export { default as RatingField } from './components/fields/RatingField.svelte';
|
|
114
|
+
export { default as CurrencyField } from './components/fields/CurrencyField.svelte';
|
|
115
|
+
export { default as PhoneField } from './components/fields/PhoneField.svelte';
|
|
114
116
|
export { CreateButton, EditButton, DeleteButton, ShowButton, ListButton, RefreshButton, ExportButton, ImportButton, SaveButton, CloneButton, } from './components/buttons/index.js';
|
|
115
117
|
export { Button, buttonVariants } from './components/ui/button/index.js';
|
|
116
118
|
export { Input } from './components/ui/input/index.js';
|
|
@@ -221,3 +223,4 @@ export { default as BatchActionBar } from './components/BatchActionBar.svelte';
|
|
|
221
223
|
export type { DateRangeFormat, DateRangeValue } from './components/fields/DateRangeField.svelte';
|
|
222
224
|
export type { PercentTone } from './components/fields/PercentField.svelte';
|
|
223
225
|
export type { RatingSize } from './components/fields/RatingField.svelte';
|
|
226
|
+
export type { CurrencyTone } from './components/fields/CurrencyField.svelte';
|
package/dist/index.js
CHANGED
|
@@ -114,6 +114,8 @@ export { default as AvatarField } from './components/fields/AvatarField.svelte';
|
|
|
114
114
|
export { default as CodeField } from './components/fields/CodeField.svelte';
|
|
115
115
|
export { default as PercentField } from './components/fields/PercentField.svelte';
|
|
116
116
|
export { default as RatingField } from './components/fields/RatingField.svelte';
|
|
117
|
+
export { default as CurrencyField } from './components/fields/CurrencyField.svelte';
|
|
118
|
+
export { default as PhoneField } from './components/fields/PhoneField.svelte';
|
|
117
119
|
// Rich text editor (requires @svadmin/editor) as a direct import in consumer app
|
|
118
120
|
// CRUD Buttons
|
|
119
121
|
export { CreateButton, EditButton, DeleteButton, ShowButton, ListButton, RefreshButton, ExportButton, ImportButton, SaveButton, CloneButton, } from './components/buttons/index.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"./components/*": "./dist/components/*"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@svadmin/core": "^0.41.
|
|
42
|
+
"@svadmin/core": "^0.41.1",
|
|
43
43
|
"@tanstack/svelte-query": "^6.1.38",
|
|
44
44
|
"highlight.js": "^11.11.1",
|
|
45
45
|
"isomorphic-dompurify": "^3.22.0",
|