@svadmin/ui 0.53.1 → 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.
@@ -8,24 +8,47 @@
8
8
  import StatusBadge from './content/StatusBadge.svelte';
9
9
 
10
10
  const i18n = useTranslation();
11
- interface Integration { id: string; name: string; description: string; connected: boolean; Icon: Component; }
12
- let integrations = $state<Integration[]>([
13
- { id: 'source-control', name: 'Source Control', description: 'Sync repository metadata, deployments, and commit activity.', connected: true, Icon: FolderGit },
14
- { id: 'chatops', name: 'ChatOps', description: 'Send operational alerts and audit summaries to workspace channels.', connected: false, Icon: MessageCircle },
15
- { id: 'identity-cloud', name: 'Identity Cloud', description: 'Configure SSO and workspace identity handoff.', connected: true, Icon: Cloud },
16
- { id: 'webhooks', name: 'Webhooks', description: 'Register outbound webhooks for internal tools and monitors.', connected: false, Icon: Plug },
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
- function toggleConnection(id: string, connected: boolean) { integrations = integrations.map((item) => item.id === id ? { ...item, connected } : item); }
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 integrations as integration (integration.id)}
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"><StatusBadge status={integration.connected ? 'success' : 'neutral'} label={integration.connected ? i18n.t('integrations.statusConnected') : i18n.t('integrations.statusDisconnected')} /><Switch checked={integration.connected} onCheckedChange={(checked) => toggleConnection(integration.id, checked)} aria-label={integration.name} /></div>
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
- declare const IntegrationsSettings: Component<Record<string, never>, {}, "">;
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 ─────────────────────────────────
@@ -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;
@@ -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;
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.53.1",
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.0",
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",