@svadmin/ui 0.55.0 → 0.56.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.
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
- import type { Snippet } from 'svelte';
3
- import type { HTMLAttributes } from 'svelte/elements';
4
- import { cn, type WithElementRef } from '../utils.js';
5
- import * as Sheet from './ui/sheet/index.js';
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import { cn, type WithElementRef } from "../utils.js";
5
+ import * as Sheet from "./ui/sheet/index.js";
6
6
 
7
7
  type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
8
8
  open?: boolean;
@@ -11,30 +11,32 @@
11
11
  description?: string;
12
12
  descriptionId?: string;
13
13
  closeLabel?: string;
14
- side?: 'left' | 'right';
14
+ side?: "left" | "right";
15
15
  width?: string;
16
16
  onClose?: () => void;
17
17
  children?: Snippet;
18
18
  footer?: Snippet;
19
+ extra?: Snippet;
19
20
  };
20
21
 
21
22
  let {
22
23
  open = $bindable(false),
23
- title = '',
24
- description = '',
25
- side = 'right',
26
- width = 'max-w-md w-full sm:max-w-lg',
24
+ title = "",
25
+ description = "",
26
+ side = "right",
27
+ width = "max-w-md w-full sm:max-w-lg",
27
28
  ref = $bindable(null),
28
29
  class: className,
29
- 'aria-label': ariaLabel,
30
- 'aria-labelledby': ariaLabelledby,
31
- 'aria-describedby': ariaDescribedby,
30
+ "aria-label": ariaLabel,
31
+ "aria-labelledby": ariaLabelledby,
32
+ "aria-describedby": ariaDescribedby,
32
33
  titleId,
33
34
  descriptionId,
34
- closeLabel = 'Close',
35
+ closeLabel = "Close",
35
36
  onClose,
36
37
  children,
37
38
  footer,
39
+ extra,
38
40
  ...restProps
39
41
  }: Props = $props();
40
42
 
@@ -50,34 +52,43 @@
50
52
  {side}
51
53
  onClose={handleClose}
52
54
  {closeLabel}
53
- class={cn('overflow-y-auto', width, className)}
55
+ class={cn("gap-0 p-0 overflow-hidden", width, className)}
54
56
  role="dialog"
55
57
  aria-modal="true"
56
- aria-label={(ariaLabelledby || titleId) ? ariaLabel : (ariaLabel || title || 'Details')}
58
+ aria-label={(ariaLabelledby || titleId) ? ariaLabel : (ariaLabel || title || "Details")}
57
59
  aria-labelledby={ariaLabelledby || (titleId ? titleId : undefined)}
58
60
  aria-describedby={ariaDescribedby || (descriptionId ? descriptionId : undefined)}
59
61
  {...restProps}
60
62
  >
61
- <Sheet.Content class="flex h-full flex-col">
62
- {#if title || description}
63
- <Sheet.Header>
64
- {#if title}
65
- <Sheet.Title id={titleId}>{title}</Sheet.Title>
66
- {/if}
67
- {#if description}
68
- <Sheet.Description id={descriptionId}>{description}</Sheet.Description>
69
- {/if}
63
+ <Sheet.Content class="flex h-full flex-col gap-0 p-0">
64
+ {#if title || description || extra}
65
+ <Sheet.Header class="flex-shrink-0 border-b border-border px-6 py-4 pr-12">
66
+ <div class="flex items-start justify-between gap-4">
67
+ <div class="flex min-w-0 flex-1 flex-col gap-1.5">
68
+ {#if title}
69
+ <Sheet.Title id={titleId}>{title}</Sheet.Title>
70
+ {/if}
71
+ {#if description}
72
+ <Sheet.Description id={descriptionId}>{description}</Sheet.Description>
73
+ {/if}
74
+ </div>
75
+ {#if extra}
76
+ <div class="flex flex-shrink-0 items-center gap-2">
77
+ {@render extra()}
78
+ </div>
79
+ {/if}
80
+ </div>
70
81
  </Sheet.Header>
71
82
  {/if}
72
83
 
73
- <div class="flex-1 overflow-y-auto py-2">
84
+ <div class="flex-1 overflow-y-auto p-6">
74
85
  {#if children}
75
86
  {@render children()}
76
87
  {/if}
77
88
  </div>
78
89
 
79
90
  {#if footer}
80
- <Sheet.Footer>
91
+ <Sheet.Footer class="mt-auto flex-shrink-0 border-t border-border px-6 py-4">
81
92
  {@render footer()}
82
93
  </Sheet.Footer>
83
94
  {/if}
@@ -1,6 +1,6 @@
1
- import type { Snippet } from 'svelte';
2
- import type { HTMLAttributes } from 'svelte/elements';
3
- import { type WithElementRef } from '../utils.js';
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import { type WithElementRef } from "../utils.js";
4
4
  type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
5
5
  open?: boolean;
6
6
  title?: string;
@@ -8,11 +8,12 @@ type Props = WithElementRef<HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
8
8
  description?: string;
9
9
  descriptionId?: string;
10
10
  closeLabel?: string;
11
- side?: 'left' | 'right';
11
+ side?: "left" | "right";
12
12
  width?: string;
13
13
  onClose?: () => void;
14
14
  children?: Snippet;
15
15
  footer?: Snippet;
16
+ extra?: Snippet;
16
17
  };
17
18
  declare const DetailDrawer: import("svelte").Component<Props, {}, "open" | "ref">;
18
19
  type DetailDrawer = ReturnType<typeof DetailDrawer>;
@@ -1,3 +1,7 @@
1
+ <script module lang="ts">
2
+ export type WorkspaceActionBarTone = 'default' | 'warning' | 'success' | 'destructive' | 'info';
3
+ </script>
4
+
1
5
  <script lang="ts">
2
6
  import type { Snippet } from 'svelte';
3
7
  import { cn } from '../../utils.js';
@@ -6,12 +10,29 @@
6
10
  title: string;
7
11
  description?: string;
8
12
  status?: string;
13
+ statusTone?: WorkspaceActionBarTone;
9
14
  primaryAction: Snippet;
10
15
  secondaryActions?: Snippet;
11
16
  class?: string;
12
17
  }
13
18
 
14
- let { title, description, status, primaryAction, secondaryActions, class: className = '' }: Props = $props();
19
+ let {
20
+ title,
21
+ description,
22
+ status,
23
+ statusTone = 'warning',
24
+ primaryAction,
25
+ secondaryActions,
26
+ class: className = '',
27
+ }: Props = $props();
28
+
29
+ const toneClassMap: Record<WorkspaceActionBarTone, string> = {
30
+ default: 'bg-muted text-muted-foreground',
31
+ warning: 'bg-warning/15 text-warning-foreground',
32
+ success: 'bg-success/15 text-success',
33
+ destructive: 'bg-destructive/15 text-destructive',
34
+ info: 'bg-info/15 text-info',
35
+ };
15
36
  </script>
16
37
 
17
38
  <section
@@ -21,7 +42,11 @@
21
42
  >
22
43
  <div class="min-w-0">
23
44
  <div class="flex flex-wrap items-center gap-2">
24
- {#if status}<span class="rounded-sm bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning-foreground">{status}</span>{/if}
45
+ {#if status}
46
+ <span class={cn('rounded-sm px-2 py-0.5 text-xs font-medium', toneClassMap[statusTone] || toneClassMap.warning)}>
47
+ {status}
48
+ </span>
49
+ {/if}
25
50
  <h2 class="text-sm font-semibold text-foreground">{title}</h2>
26
51
  </div>
27
52
  {#if description}<p class="mt-1 text-xs leading-5 text-muted-foreground">{description}</p>{/if}
@@ -1,8 +1,10 @@
1
+ export type WorkspaceActionBarTone = 'default' | 'warning' | 'success' | 'destructive' | 'info';
1
2
  import type { Snippet } from 'svelte';
2
3
  interface Props {
3
4
  title: string;
4
5
  description?: string;
5
6
  status?: string;
7
+ statusTone?: WorkspaceActionBarTone;
6
8
  primaryAction: Snippet;
7
9
  secondaryActions?: Snippet;
8
10
  class?: string;
@@ -0,0 +1,94 @@
1
+ <script module lang="ts">
2
+ import type { Component } from 'svelte';
3
+
4
+ export interface WorkspaceTabItem {
5
+ id: string;
6
+ label: string;
7
+ icon?: Component<{ class?: string; 'aria-hidden'?: string | boolean }>;
8
+ badge?: string | number;
9
+ disabled?: boolean;
10
+ description?: string;
11
+ }
12
+ </script>
13
+
14
+ <script lang="ts">
15
+ import { cn } from '../../utils.js';
16
+
17
+ interface Props {
18
+ items: WorkspaceTabItem[];
19
+ activeId: string;
20
+ onselect?: (item: WorkspaceTabItem) => void;
21
+ variant?: 'segmented' | 'pill' | 'underline';
22
+ ariaLabel?: string;
23
+ class?: string;
24
+ }
25
+
26
+ let {
27
+ items,
28
+ activeId,
29
+ onselect,
30
+ variant = 'segmented',
31
+ ariaLabel = 'Workspace tabs',
32
+ class: className = '',
33
+ }: Props = $props();
34
+ </script>
35
+
36
+ <nav
37
+ aria-label={ariaLabel}
38
+ class={cn(
39
+ 'flex items-center gap-1 overflow-x-auto',
40
+ variant === 'segmented' && 'rounded-lg border border-border bg-muted/40 p-1',
41
+ variant === 'pill' && 'gap-1.5 p-0.5',
42
+ variant === 'underline' && 'gap-2 border-b border-border',
43
+ className,
44
+ )}
45
+ data-svadmin-workspace-tab-bar
46
+ data-variant={variant}
47
+ >
48
+ {#each items as item (item.id)}
49
+ {@const Icon = item.icon}
50
+ {@const isActive = item.id === activeId}
51
+ <button
52
+ type="button"
53
+ disabled={item.disabled}
54
+ class={cn(
55
+ 'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
56
+ variant === 'segmented' && [
57
+ isActive
58
+ ? 'bg-background text-foreground shadow-sm font-semibold'
59
+ : 'text-muted-foreground hover:bg-background/50 hover:text-foreground',
60
+ ],
61
+ variant === 'pill' && [
62
+ isActive
63
+ ? 'bg-primary text-primary-foreground font-semibold'
64
+ : 'bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground',
65
+ ],
66
+ variant === 'underline' && [
67
+ 'rounded-none border-b-2 px-3 py-2',
68
+ isActive
69
+ ? 'border-primary text-primary font-semibold'
70
+ : 'border-transparent text-muted-foreground hover:border-border hover:text-foreground',
71
+ ],
72
+ )}
73
+ aria-current={isActive ? 'page' : undefined}
74
+ onclick={() => onselect?.(item)}
75
+ >
76
+ {#if Icon}
77
+ <Icon class="size-4 shrink-0" aria-hidden="true" />
78
+ {/if}
79
+ <span>{item.label}</span>
80
+ {#if item.badge !== undefined && item.badge !== ''}
81
+ <span
82
+ class={cn(
83
+ 'inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-[0.6875rem] font-bold leading-none tabular-nums',
84
+ isActive
85
+ ? (variant === 'pill' ? 'bg-primary-foreground/20 text-primary-foreground' : 'bg-primary/10 text-primary')
86
+ : 'bg-muted text-muted-foreground',
87
+ )}
88
+ >
89
+ {item.badge}
90
+ </span>
91
+ {/if}
92
+ </button>
93
+ {/each}
94
+ </nav>
@@ -0,0 +1,23 @@
1
+ import type { Component } from 'svelte';
2
+ export interface WorkspaceTabItem {
3
+ id: string;
4
+ label: string;
5
+ icon?: Component<{
6
+ class?: string;
7
+ 'aria-hidden'?: string | boolean;
8
+ }>;
9
+ badge?: string | number;
10
+ disabled?: boolean;
11
+ description?: string;
12
+ }
13
+ interface Props {
14
+ items: WorkspaceTabItem[];
15
+ activeId: string;
16
+ onselect?: (item: WorkspaceTabItem) => void;
17
+ variant?: 'segmented' | 'pill' | 'underline';
18
+ ariaLabel?: string;
19
+ class?: string;
20
+ }
21
+ declare const WorkspaceTabBar: Component<Props, {}, "">;
22
+ type WorkspaceTabBar = ReturnType<typeof WorkspaceTabBar>;
23
+ export default WorkspaceTabBar;
@@ -5,6 +5,9 @@ export { default as PageToolbar } from './PageToolbar.svelte';
5
5
  export { default as WorkspaceLayout } from './WorkspaceLayout.svelte';
6
6
  export { default as WorkspaceStageStepper } from './WorkspaceStageStepper.svelte';
7
7
  export { default as WorkspaceActionBar } from './WorkspaceActionBar.svelte';
8
+ export type { WorkspaceActionBarTone } from './WorkspaceActionBar.svelte';
9
+ export { default as WorkspaceTabBar } from './WorkspaceTabBar.svelte';
10
+ export type { WorkspaceTabItem } from './WorkspaceTabBar.svelte';
8
11
  export { default as WorkspaceInspector } from './WorkspaceInspector.svelte';
9
12
  export { default as WorkspaceSplitPane } from './WorkspaceSplitPane.svelte';
10
13
  export { default as SettingsGroup } from './SettingsGroup.svelte';
@@ -5,6 +5,7 @@ export { default as PageToolbar } from './PageToolbar.svelte';
5
5
  export { default as WorkspaceLayout } from './WorkspaceLayout.svelte';
6
6
  export { default as WorkspaceStageStepper } from './WorkspaceStageStepper.svelte';
7
7
  export { default as WorkspaceActionBar } from './WorkspaceActionBar.svelte';
8
+ export { default as WorkspaceTabBar } from './WorkspaceTabBar.svelte';
8
9
  export { default as WorkspaceInspector } from './WorkspaceInspector.svelte';
9
10
  export { default as WorkspaceSplitPane } from './WorkspaceSplitPane.svelte';
10
11
  export { default as SettingsGroup } from './SettingsGroup.svelte';
@@ -25,6 +25,7 @@ import CodeField from './fields/CodeField.svelte';
25
25
  import AvatarField from './fields/AvatarField.svelte';
26
26
  import CopyField from './fields/CopyField.svelte';
27
27
  import FileField from './fields/FileField.svelte';
28
+ import MarkdownField from './fields/MarkdownField.svelte';
28
29
  /**
29
30
  * Built-in display components for show/detail pages.
30
31
  * Keys match FieldDefinition.type values.
@@ -55,6 +56,7 @@ export const builtinDisplayComponents = {
55
56
  avatar: AvatarField,
56
57
  copy: CopyField,
57
58
  file: FileField,
59
+ markdown: MarkdownField,
58
60
  };
59
61
  // ─── Custom component registry ─────────────────────────────────
60
62
  // Users can register custom field display components at runtime
@@ -1,17 +1,58 @@
1
1
  <script lang="ts">
2
2
  /* eslint-disable svelte/no-at-html-tags */
3
- import * as DOMPurifyPkg from "isomorphic-dompurify";
4
- interface Props { value: string | null | undefined }
5
- let { value }: Props = $props();
3
+ import { onMount } from "svelte";
6
4
 
7
- const DOMPurify = 'default' in DOMPurifyPkg ? DOMPurifyPkg.default : DOMPurifyPkg;
5
+ interface Props {
6
+ value?: string | null | undefined;
7
+ class?: string;
8
+ }
8
9
 
9
- const sanitizedHtml = $derived(
10
- value ? DOMPurify.sanitize(value, { USE_PROFILES: { html: true } }) : ''
11
- );
10
+ let { value, class: className = "" }: Props = $props();
11
+
12
+ interface DomPurifyLike {
13
+ sanitize(source: string | Node, config?: Record<string, unknown>): string;
14
+ }
15
+ let DOMPurify: DomPurifyLike | null = $state(null);
16
+
17
+ onMount(() => {
18
+ let cancelled = false;
19
+ import("isomorphic-dompurify")
20
+ .then((pkg) => {
21
+ if (!cancelled) {
22
+ const mod = pkg as unknown as { default?: DomPurifyLike; sanitize?: DomPurifyLike["sanitize"] };
23
+ const loaded: DomPurifyLike | undefined =
24
+ mod.default && typeof mod.default.sanitize === "function"
25
+ ? mod.default
26
+ : typeof mod.sanitize === "function"
27
+ ? (mod as DomPurifyLike)
28
+ : undefined;
29
+ if (loaded) {
30
+ DOMPurify = loaded;
31
+ }
32
+ }
33
+ })
34
+ .catch(() => {});
35
+ return () => {
36
+ cancelled = true;
37
+ };
38
+ });
39
+
40
+ const sanitizedHtml = $derived.by(() => {
41
+ if (!value) return "";
42
+ if (DOMPurify && typeof DOMPurify.sanitize === "function") {
43
+ return DOMPurify.sanitize(value, { USE_PROFILES: { html: true } });
44
+ }
45
+ return String(value)
46
+ .replace(/&/g, "&amp;")
47
+ .replace(/</g, "&lt;")
48
+ .replace(/>/g, "&gt;")
49
+ .replace(/"/g, "&quot;")
50
+ .replace(/'/g, "&#039;");
51
+ });
12
52
  </script>
13
- {#if value}
14
- <div class="prose prose-sm dark:prose-invert max-w-none leading-relaxed [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.875em] [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_a]:text-primary">{@html sanitizedHtml}</div>
15
- {:else}
53
+
54
+ {#if !value}
16
55
  <span class="text-muted-foreground">—</span>
56
+ {:else}
57
+ <div class="prose prose-sm dark:prose-invert max-w-none leading-relaxed [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.875em] [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_a]:text-primary {className}">{@html sanitizedHtml}</div>
17
58
  {/if}
@@ -1,5 +1,6 @@
1
1
  interface Props {
2
- value: string | null | undefined;
2
+ value?: string | null | undefined;
3
+ class?: string;
3
4
  }
4
5
  declare const MarkdownField: import("svelte").Component<Props, {}, "">;
5
6
  type MarkdownField = ReturnType<typeof MarkdownField>;
@@ -31,10 +31,16 @@
31
31
  class: className = '',
32
32
  }: Props = $props();
33
33
 
34
+ const normalizedPrecision = $derived(
35
+ precision != null && Number.isFinite(precision)
36
+ ? Math.min(20, Math.max(0, Math.trunc(precision)))
37
+ : undefined
38
+ );
39
+
34
40
  const formatted = $derived.by(() => {
35
41
  if (value == null || value === '') return nullLabel;
36
42
  const num = Number(value);
37
- if (isNaN(num)) return nullLabel;
43
+ if (!Number.isFinite(num)) return nullLabel;
38
44
 
39
45
  const opts: Intl.NumberFormatOptions = { ...options };
40
46
 
@@ -46,9 +52,9 @@
46
52
  opts.notation = 'compact';
47
53
  opts.compactDisplay = 'short';
48
54
  }
49
- if (precision != null) {
50
- opts.minimumFractionDigits = precision;
51
- opts.maximumFractionDigits = precision;
55
+ if (normalizedPrecision !== undefined) {
56
+ opts.minimumFractionDigits = normalizedPrecision;
57
+ opts.maximumFractionDigits = normalizedPrecision;
52
58
  }
53
59
  if (signed && num > 0 && !currency) {
54
60
  opts.signDisplay = 'always';
package/dist/index.d.ts CHANGED
@@ -170,6 +170,9 @@ export { default as WorkspaceLayout } from './components/content/WorkspaceLayout
170
170
  export { default as WorkspaceStageStepper } from './components/content/WorkspaceStageStepper.svelte';
171
171
  export type { WorkspaceStage, WorkspaceStageStatus } from './components/content/WorkspaceStageStepper.svelte';
172
172
  export { default as WorkspaceActionBar } from './components/content/WorkspaceActionBar.svelte';
173
+ export type { WorkspaceActionBarTone } from './components/content/WorkspaceActionBar.svelte';
174
+ export { default as WorkspaceTabBar } from './components/content/WorkspaceTabBar.svelte';
175
+ export type { WorkspaceTabItem } from './components/content/WorkspaceTabBar.svelte';
173
176
  export { default as WorkspaceInspector } from './components/content/WorkspaceInspector.svelte';
174
177
  export { default as WorkspaceSplitPane } from './components/content/WorkspaceSplitPane.svelte';
175
178
  export { default as SettingsGroup } from './components/content/SettingsGroup.svelte';
package/dist/index.js CHANGED
@@ -184,6 +184,7 @@ export { default as PageToolbar } from './components/content/PageToolbar.svelte'
184
184
  export { default as WorkspaceLayout } from './components/content/WorkspaceLayout.svelte';
185
185
  export { default as WorkspaceStageStepper } from './components/content/WorkspaceStageStepper.svelte';
186
186
  export { default as WorkspaceActionBar } from './components/content/WorkspaceActionBar.svelte';
187
+ export { default as WorkspaceTabBar } from './components/content/WorkspaceTabBar.svelte';
187
188
  export { default as WorkspaceInspector } from './components/content/WorkspaceInspector.svelte';
188
189
  export { default as WorkspaceSplitPane } from './components/content/WorkspaceSplitPane.svelte';
189
190
  export { default as SettingsGroup } from './components/content/SettingsGroup.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [