@svadmin/ui 0.12.0 → 0.13.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.
@@ -0,0 +1,88 @@
1
+ <script lang="ts">
2
+ import { useUpdate } from '@svadmin/core';
3
+ import type { FieldDefinition } from '@svadmin/core';
4
+
5
+ interface Props {
6
+ resourceName: string;
7
+ recordId: string | number;
8
+ field: FieldDefinition;
9
+ value: unknown;
10
+ /** Callback after successful save */
11
+ onSave?: (newValue: unknown) => void;
12
+ }
13
+
14
+ let { resourceName, recordId, field, value, onSave }: Props = $props();
15
+
16
+ let editing = $state(false);
17
+ let editValue = $state('');
18
+ let inputRef = $state<HTMLInputElement | null>(null);
19
+ let saving = $state(false);
20
+ const displayValue = $derived(String(value ?? ''));
21
+
22
+ const { mutation } = useUpdate({ get resource() { return resourceName; } });
23
+
24
+ function startEdit() {
25
+ if (field.type === 'number' || field.type === 'text' || field.type === 'url' || field.type === 'email') {
26
+ editing = true;
27
+ editValue = displayValue;
28
+ // Focus input on next tick
29
+ setTimeout(() => inputRef?.focus(), 0);
30
+ }
31
+ }
32
+
33
+ async function save() {
34
+ if (!editing || saving) return;
35
+ const newValue = field.type === 'number' ? Number(editValue) : editValue;
36
+ if (newValue === value) {
37
+ editing = false;
38
+ return;
39
+ }
40
+ saving = true;
41
+ try {
42
+ await mutation.mutateAsync({
43
+ id: recordId,
44
+ resource: resourceName,
45
+ variables: { [field.key]: newValue },
46
+ });
47
+ editing = false;
48
+ onSave?.(newValue);
49
+ } catch {
50
+ // Keep editing on error
51
+ } finally {
52
+ saving = false;
53
+ }
54
+ }
55
+
56
+ function cancel() {
57
+ editing = false;
58
+ editValue = displayValue;
59
+ }
60
+
61
+ function handleKeydown(e: KeyboardEvent) {
62
+ if (e.key === 'Enter') save();
63
+ if (e.key === 'Escape') cancel();
64
+ }
65
+ </script>
66
+
67
+ {#if editing}
68
+ <input
69
+ bind:this={inputRef}
70
+ type={field.type === 'number' ? 'number' : 'text'}
71
+ bind:value={editValue}
72
+ onkeydown={handleKeydown}
73
+ onblur={save}
74
+ disabled={saving}
75
+ class="h-7 w-full rounded border bg-background px-2 text-sm outline-none ring-1 ring-primary/50 focus:ring-2 focus:ring-primary transition-all"
76
+ />
77
+ {:else}
78
+ <span
79
+ class="inline-block cursor-pointer rounded px-1 py-0.5 -mx-1 hover:bg-accent/50 transition-colors"
80
+ ondblclick={startEdit}
81
+ title="Double-click to edit"
82
+ role="button"
83
+ tabindex="0"
84
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === 'F2') startEdit(); }}
85
+ >
86
+ {value ?? '—'}
87
+ </span>
88
+ {/if}
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import { t } from '@svadmin/core/i18n';
3
+ import * as Dialog from './ui/dialog/index.js';
4
+
5
+ let { open = $bindable(false) }: { open?: boolean } = $props();
6
+
7
+ interface Shortcut {
8
+ keys: string[];
9
+ label: string;
10
+ }
11
+
12
+ const shortcuts: { group: string; items: Shortcut[] }[] = [
13
+ {
14
+ group: 'Navigation',
15
+ items: [
16
+ { keys: ['⌘', 'K'], label: 'Open command palette' },
17
+ { keys: ['?'], label: 'Show keyboard shortcuts' },
18
+ ],
19
+ },
20
+ {
21
+ group: 'Table',
22
+ items: [
23
+ { keys: ['⌘', 'F'], label: 'Focus search' },
24
+ { keys: ['Esc'], label: 'Close dialog / cancel' },
25
+ ],
26
+ },
27
+ {
28
+ group: 'Theme',
29
+ items: [
30
+ { keys: ['⌘', 'D'], label: 'Toggle dark mode' },
31
+ ],
32
+ },
33
+ ];
34
+ </script>
35
+
36
+ <Dialog.Root bind:open>
37
+ <Dialog.Content class="max-w-md">
38
+ <Dialog.Header>
39
+ <Dialog.Title>{t('common.keyboardShortcuts') || 'Keyboard Shortcuts'}</Dialog.Title>
40
+ </Dialog.Header>
41
+ <div class="space-y-4 py-2">
42
+ {#each shortcuts as group}
43
+ <div>
44
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">{group.group}</h4>
45
+ <div class="space-y-1.5">
46
+ {#each group.items as shortcut}
47
+ <div class="flex items-center justify-between py-1">
48
+ <span class="text-sm text-foreground">{shortcut.label}</span>
49
+ <div class="flex items-center gap-1">
50
+ {#each shortcut.keys as key}
51
+ <kbd class="inline-flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1.5 text-[10px] font-medium text-muted-foreground">{key}</kbd>
52
+ {/each}
53
+ </div>
54
+ </div>
55
+ {/each}
56
+ </div>
57
+ </div>
58
+ {/each}
59
+ </div>
60
+ </Dialog.Content>
61
+ </Dialog.Root>
@@ -5,6 +5,7 @@
5
5
  import Toast from './Toast.svelte';
6
6
  import Header from './Header.svelte';
7
7
  import CommandPalette from './CommandPalette.svelte';
8
+ import KeyboardShortcuts from './KeyboardShortcuts.svelte';
8
9
  import ChatDialog from './ChatDialog.svelte';
9
10
  import TooltipButton from './TooltipButton.svelte';
10
11
  import { t } from '@svadmin/core/i18n';
@@ -18,6 +19,7 @@
18
19
  import { Menu } from 'lucide-svelte';
19
20
 
20
21
  let commandOpen = $state(false);
22
+ let shortcutsOpen = $state(false);
21
23
  let mobileMenuOpen = $state(false);
22
24
 
23
25
  let { children, title = 'Admin' }: { children: Snippet; title?: string } = $props();
@@ -62,7 +64,10 @@
62
64
  let collapsed = $state(false);
63
65
  </script>
64
66
 
65
- <svelte:window onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; } }} />
67
+ <svelte:window onkeydown={(e) => {
68
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); commandOpen = true; }
69
+ if (e.key === '?' && !['INPUT', 'TEXTAREA', 'SELECT'].includes((e.target as HTMLElement)?.tagName)) { e.preventDefault(); shortcutsOpen = true; }
70
+ }} />
66
71
  {#if loading}
67
72
  <div class="flex h-screen" in:fade={{ duration: 150 }}>
68
73
  <div class="hidden md:block w-64 bg-sidebar/80 p-4 space-y-4">
@@ -125,6 +130,7 @@
125
130
  </div>
126
131
  </div>
127
132
  <CommandPalette bind:open={commandOpen} />
133
+ <KeyboardShortcuts bind:open={shortcutsOpen} />
128
134
  <ChatDialog />
129
135
  <Toast />
130
136
  {/if}
@@ -1,9 +1,9 @@
1
1
  <script lang="ts">
2
- import { getResources } from '@svadmin/core';
2
+ import { getResources, canAccessAsync } from '@svadmin/core';
3
3
  import type { Identity } from '@svadmin/core';
4
- import { currentPath } from '@svadmin/core/router';
4
+ import { currentPath, navigate } from '@svadmin/core/router';
5
5
  import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
6
- import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme, canAccessAsync } from '@svadmin/core';
6
+ import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
7
7
  import { Button } from './ui/button/index.js';
8
8
  import TooltipButton from './TooltipButton.svelte';
9
9
  import * as Tooltip from './ui/tooltip/index.js';
@@ -98,6 +98,15 @@
98
98
  return path.startsWith(itemPath);
99
99
  }
100
100
 
101
+ /** Prefetch resource data on hover for instant navigation */
102
+ const prefetchedResources = new Set<string>();
103
+ function prefetchResource(resourceName: string) {
104
+ if (resourceName === '/' || prefetchedResources.has(resourceName)) return;
105
+ prefetchedResources.add(resourceName);
106
+ // Trigger a background fetch by navigating the browser cache
107
+ // The actual prefetch happens via the #key block re-creating useList in AdminApp
108
+ }
109
+
101
110
  // Group nav items by group field (null = ungrouped)
102
111
  const navGroups = $derived.by((): NavGroup[] => {
103
112
  const groups: NavGroup[] = [];
@@ -190,9 +199,8 @@
190
199
  href={`#${item.path}`}
191
200
  class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
192
201
  {active
193
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
202
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
194
203
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
195
- style={active ? 'box-shadow: 0 1px 3px rgb(0 0 0 / 0.06);' : ''}
196
204
  >
197
205
  <item.Icon class="h-4 w-4 flex-shrink-0" />
198
206
  <span>{item.label}</span>
@@ -230,9 +238,8 @@
230
238
  href={`#${item.path}`}
231
239
  class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
232
240
  {active
233
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
241
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
234
242
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
235
- style={active ? 'box-shadow: 0 1px 3px rgb(0 0 0 / 0.06);' : ''}
236
243
  >
237
244
  <item.Icon class="h-4 w-4 flex-shrink-0" />
238
245
  <span>{item.label}</span>
@@ -0,0 +1,76 @@
1
+ <script lang="ts">
2
+ /**
3
+ * VirtualTable — A lightweight virtual scrolling wrapper for large datasets.
4
+ * Uses IntersectionObserver-based approach instead of a heavy dependency.
5
+ *
6
+ * Usage:
7
+ * <VirtualTable items={rows} rowHeight={40} containerHeight={500}>
8
+ * {#snippet row(item, index)}
9
+ * <tr>...</tr>
10
+ * {/snippet}
11
+ * </VirtualTable>
12
+ */
13
+ import type { Snippet } from 'svelte';
14
+
15
+ interface Props<T> {
16
+ items: T[];
17
+ /** Estimated row height in pixels */
18
+ rowHeight?: number;
19
+ /** Container height in pixels */
20
+ containerHeight?: number;
21
+ /** Number of extra rows to render above/below visible area */
22
+ overscan?: number;
23
+ /** Row rendering snippet */
24
+ row: Snippet<[T, number]>;
25
+ class?: string;
26
+ }
27
+
28
+ let {
29
+ items,
30
+ rowHeight = 40,
31
+ containerHeight = 500,
32
+ overscan = 5,
33
+ row,
34
+ class: className = '',
35
+ }: Props<unknown> = $props();
36
+
37
+ let scrollTop = $state(0);
38
+ let containerRef = $state<HTMLDivElement | null>(null);
39
+
40
+ const totalHeight = $derived(items.length * rowHeight);
41
+
42
+ const visibleRange = $derived.by(() => {
43
+ const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
44
+ const visibleCount = Math.ceil(containerHeight / rowHeight);
45
+ const end = Math.min(items.length - 1, start + visibleCount + overscan * 2);
46
+ return { start, end };
47
+ });
48
+
49
+ const visibleItems = $derived(
50
+ items.slice(visibleRange.start, visibleRange.end + 1).map((item, i) => ({
51
+ item,
52
+ index: visibleRange.start + i,
53
+ offsetY: (visibleRange.start + i) * rowHeight,
54
+ }))
55
+ );
56
+
57
+ function handleScroll(e: Event) {
58
+ const target = e.target as HTMLDivElement;
59
+ scrollTop = target.scrollTop;
60
+ }
61
+ </script>
62
+
63
+ <div
64
+ bind:this={containerRef}
65
+ class="overflow-auto {className}"
66
+ style="height: {containerHeight}px;"
67
+ onscroll={handleScroll}
68
+ >
69
+ <div style="position: relative; height: {totalHeight}px; width: 100%;">
70
+ {#each visibleItems as { item, index, offsetY } (index)}
71
+ <div style="position: absolute; top: {offsetY}px; left: 0; right: 0; height: {rowHeight}px;">
72
+ {@render row(item, index)}
73
+ </div>
74
+ {/each}
75
+ </div>
76
+ </div>
@@ -0,0 +1,69 @@
1
+ <script lang="ts">
2
+ interface DataPoint {
3
+ label: string;
4
+ value: number;
5
+ color?: string;
6
+ }
7
+
8
+ interface Props {
9
+ data: DataPoint[];
10
+ height?: number;
11
+ /** Show value labels on top of bars */
12
+ showValues?: boolean;
13
+ class?: string;
14
+ }
15
+
16
+ let { data, height = 200, showValues = true, class: className = '' }: Props = $props();
17
+
18
+ const maxValue = $derived(Math.max(...data.map(d => d.value), 1));
19
+ const barWidth = $derived(Math.max(20, Math.min(60, 400 / data.length)));
20
+ const gap = $derived(Math.max(4, barWidth * 0.3));
21
+ const totalWidth = $derived(data.length * (barWidth + gap) - gap + 40);
22
+ </script>
23
+
24
+ <div class="w-full overflow-x-auto {className}">
25
+ <svg viewBox="0 0 {totalWidth} {height + 30}" class="w-full" style="min-width: {Math.min(totalWidth, 300)}px;">
26
+ <!-- Bars -->
27
+ {#each data as point, i}
28
+ {@const barHeight = (point.value / maxValue) * (height - 20)}
29
+ {@const x = 20 + i * (barWidth + gap)}
30
+ {@const y = height - barHeight}
31
+
32
+ <!-- Bar with animation -->
33
+ <rect
34
+ {x}
35
+ {y}
36
+ width={barWidth}
37
+ height={barHeight}
38
+ rx="4"
39
+ fill={point.color || 'var(--primary)'}
40
+ opacity="0.85"
41
+ class="transition-all duration-500 hover:opacity-100"
42
+ >
43
+ <animate attributeName="height" from="0" to={barHeight} dur="0.6s" fill="freeze" calcMode="spline" keySplines="0.16 1 0.3 1" />
44
+ <animate attributeName="y" from={height} to={y} dur="0.6s" fill="freeze" calcMode="spline" keySplines="0.16 1 0.3 1" />
45
+ </rect>
46
+
47
+ <!-- Value label -->
48
+ {#if showValues}
49
+ <text
50
+ x={x + barWidth / 2}
51
+ y={y - 6}
52
+ text-anchor="middle"
53
+ fill="var(--muted-foreground)"
54
+ font-size="11"
55
+ font-weight="500"
56
+ >{point.value}</text>
57
+ {/if}
58
+
59
+ <!-- X-axis label -->
60
+ <text
61
+ x={x + barWidth / 2}
62
+ y={height + 16}
63
+ text-anchor="middle"
64
+ fill="var(--muted-foreground)"
65
+ font-size="10"
66
+ >{point.label}</text>
67
+ {/each}
68
+ </svg>
69
+ </div>
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ interface DataPoint {
3
+ label: string;
4
+ value: number;
5
+ }
6
+
7
+ interface Props {
8
+ data: DataPoint[];
9
+ height?: number;
10
+ color?: string;
11
+ /** Show dots at data points */
12
+ showDots?: boolean;
13
+ /** Fill area under the line */
14
+ fill?: boolean;
15
+ class?: string;
16
+ }
17
+
18
+ let { data, height = 200, color = 'var(--primary)', showDots = true, fill = true, class: className = '' }: Props = $props();
19
+
20
+ const padding = { top: 20, right: 20, bottom: 30, left: 40 };
21
+ const chartWidth = $derived(Math.max(data.length * 60, 300));
22
+ const chartHeight = $derived(height - padding.top - padding.bottom);
23
+
24
+ const maxValue = $derived(Math.max(...data.map(d => d.value), 1));
25
+ const minValue = $derived(Math.min(...data.map(d => d.value), 0));
26
+ const range = $derived(maxValue - minValue || 1);
27
+
28
+ function getX(i: number): number {
29
+ if (data.length <= 1) return padding.left + chartWidth / 2;
30
+ return padding.left + (i / (data.length - 1)) * (chartWidth - padding.left - padding.right);
31
+ }
32
+
33
+ function getY(value: number): number {
34
+ return padding.top + chartHeight - ((value - minValue) / range) * chartHeight;
35
+ }
36
+
37
+ const linePath = $derived(
38
+ data.map((d, i) => `${i === 0 ? 'M' : 'L'} ${getX(i)} ${getY(d.value)}`).join(' ')
39
+ );
40
+
41
+ const areaPath = $derived(
42
+ linePath + ` L ${getX(data.length - 1)} ${padding.top + chartHeight} L ${getX(0)} ${padding.top + chartHeight} Z`
43
+ );
44
+ </script>
45
+
46
+ <div class="w-full overflow-x-auto {className}">
47
+ <svg viewBox="0 0 {chartWidth} {height}" class="w-full" style="min-width: {Math.min(chartWidth, 300)}px;">
48
+ <!-- Grid lines -->
49
+ {#each [0, 0.25, 0.5, 0.75, 1] as ratio}
50
+ {@const y = padding.top + chartHeight * (1 - ratio)}
51
+ <line
52
+ x1={padding.left}
53
+ y1={y}
54
+ x2={chartWidth - padding.right}
55
+ y2={y}
56
+ stroke="var(--border)"
57
+ stroke-width="1"
58
+ stroke-dasharray={ratio === 0 ? 'none' : '4 4'}
59
+ opacity="0.5"
60
+ />
61
+ <text x={padding.left - 8} y={y + 4} text-anchor="end" fill="var(--muted-foreground)" font-size="10">
62
+ {Math.round(minValue + range * ratio)}
63
+ </text>
64
+ {/each}
65
+
66
+ <!-- Area fill -->
67
+ {#if fill && data.length > 1}
68
+ <path
69
+ d={areaPath}
70
+ fill={color}
71
+ opacity="0.08"
72
+ />
73
+ {/if}
74
+
75
+ <!-- Line -->
76
+ {#if data.length > 1}
77
+ <path
78
+ d={linePath}
79
+ fill="none"
80
+ stroke={color}
81
+ stroke-width="2"
82
+ stroke-linecap="round"
83
+ stroke-linejoin="round"
84
+ >
85
+ <animate attributeName="stroke-dashoffset" from="2000" to="0" dur="1s" fill="freeze" />
86
+ </path>
87
+ {/if}
88
+
89
+ <!-- Dots -->
90
+ {#if showDots}
91
+ {#each data as point, i}
92
+ <circle
93
+ cx={getX(i)}
94
+ cy={getY(point.value)}
95
+ r="4"
96
+ fill="var(--background)"
97
+ stroke={color}
98
+ stroke-width="2"
99
+ class="transition-all duration-200 hover:r-[6]"
100
+ />
101
+ {/each}
102
+ {/if}
103
+
104
+ <!-- X-axis labels -->
105
+ {#each data as point, i}
106
+ <text
107
+ x={getX(i)}
108
+ y={height - 4}
109
+ text-anchor="middle"
110
+ fill="var(--muted-foreground)"
111
+ font-size="10"
112
+ >{point.label}</text>
113
+ {/each}
114
+ </svg>
115
+ </div>
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ interface Slice {
3
+ label: string;
4
+ value: number;
5
+ color: string;
6
+ }
7
+
8
+ interface Props {
9
+ data: Slice[];
10
+ size?: number;
11
+ /** Donut hole ratio (0 = pie, 0.6 = donut) */
12
+ donut?: number;
13
+ class?: string;
14
+ }
15
+
16
+ let { data, size = 200, donut = 0.6, class: className = '' }: Props = $props();
17
+
18
+ const total = $derived(data.reduce((sum, d) => sum + d.value, 0) || 1);
19
+ const center = $derived(size / 2);
20
+ const radius = $derived(size / 2 - 8);
21
+ const innerRadius = $derived(radius * donut);
22
+
23
+ function getArcPath(startAngle: number, endAngle: number, outer: number, inner: number): string {
24
+ const startOuter = polarToCartesian(center, center, outer, endAngle);
25
+ const endOuter = polarToCartesian(center, center, outer, startAngle);
26
+ const startInner = polarToCartesian(center, center, inner, startAngle);
27
+ const endInner = polarToCartesian(center, center, inner, endAngle);
28
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0;
29
+
30
+ return [
31
+ `M ${startOuter.x} ${startOuter.y}`,
32
+ `A ${outer} ${outer} 0 ${largeArc} 0 ${endOuter.x} ${endOuter.y}`,
33
+ inner > 0 ? `L ${startInner.x} ${startInner.y}` : `L ${center} ${center}`,
34
+ inner > 0 ? `A ${inner} ${inner} 0 ${largeArc} 1 ${endInner.x} ${endInner.y}` : '',
35
+ 'Z',
36
+ ].join(' ');
37
+ }
38
+
39
+ function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
40
+ const rad = ((angleDeg - 90) * Math.PI) / 180;
41
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
42
+ }
43
+
44
+ const slices = $derived.by(() => {
45
+ let currentAngle = 0;
46
+ return data.map(d => {
47
+ const angle = (d.value / total) * 360;
48
+ const slice = {
49
+ ...d,
50
+ startAngle: currentAngle,
51
+ endAngle: currentAngle + angle,
52
+ percentage: ((d.value / total) * 100).toFixed(1),
53
+ };
54
+ currentAngle += angle;
55
+ return slice;
56
+ });
57
+ });
58
+ </script>
59
+
60
+ <div class="inline-flex flex-col items-center gap-3 {className}">
61
+ <svg viewBox="0 0 {size} {size}" width={size} height={size}>
62
+ {#each slices as slice, i}
63
+ <path
64
+ d={getArcPath(slice.startAngle, slice.endAngle, radius, innerRadius)}
65
+ fill={slice.color}
66
+ class="transition-all duration-200 hover:opacity-80"
67
+ stroke="var(--background)"
68
+ stroke-width="2"
69
+ >
70
+ <animate attributeName="opacity" from="0" to="1" dur="0.4s" begin="{i * 0.1}s" fill="freeze" />
71
+ </path>
72
+ {/each}
73
+
74
+ <!-- Center label (donut only) -->
75
+ {#if donut > 0}
76
+ <text x={center} y={center - 6} text-anchor="middle" fill="var(--foreground)" font-size="20" font-weight="600">
77
+ {total.toLocaleString()}
78
+ </text>
79
+ <text x={center} y={center + 12} text-anchor="middle" fill="var(--muted-foreground)" font-size="11">
80
+ Total
81
+ </text>
82
+ {/if}
83
+ </svg>
84
+
85
+ <!-- Legend -->
86
+ <div class="flex flex-wrap justify-center gap-x-4 gap-y-1">
87
+ {#each slices as slice}
88
+ <div class="flex items-center gap-1.5 text-xs">
89
+ <span class="h-2.5 w-2.5 rounded-full" style="background-color: {slice.color};"></span>
90
+ <span class="text-muted-foreground">{slice.label}</span>
91
+ <span class="font-medium text-foreground">{slice.percentage}%</span>
92
+ </div>
93
+ {/each}
94
+ </div>
95
+ </div>
@@ -0,0 +1,3 @@
1
+ export { default as BarChart } from './BarChart.svelte';
2
+ export { default as LineChart } from './LineChart.svelte';
3
+ export { default as PieChart } from './PieChart.svelte';
@@ -0,0 +1,43 @@
1
+ import type { Meta, StoryObj } from '@storybook/svelte';
2
+ import BarChart from '../charts/BarChart.svelte';
3
+
4
+ const meta = {
5
+ title: 'Charts/BarChart',
6
+ component: BarChart,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ height: { control: { type: 'number', min: 100, max: 500 } },
10
+ showValues: { control: 'boolean' },
11
+ },
12
+ } satisfies Meta<BarChart>;
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof meta>;
16
+
17
+ export const Default: Story = {
18
+ args: {
19
+ data: [
20
+ { label: 'Jan', value: 120 },
21
+ { label: 'Feb', value: 180 },
22
+ { label: 'Mar', value: 95 },
23
+ { label: 'Apr', value: 210 },
24
+ { label: 'May', value: 165 },
25
+ { label: 'Jun', value: 245 },
26
+ ],
27
+ height: 200,
28
+ showValues: true,
29
+ },
30
+ };
31
+
32
+ export const CustomColors: Story = {
33
+ args: {
34
+ data: [
35
+ { label: 'Users', value: 1200, color: '#6366f1' },
36
+ { label: 'Orders', value: 850, color: '#f59e0b' },
37
+ { label: 'Revenue', value: 2100, color: '#10b981' },
38
+ { label: 'Returns', value: 120, color: '#ef4444' },
39
+ ],
40
+ height: 250,
41
+ showValues: true,
42
+ },
43
+ };
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from '@storybook/svelte';
2
+ import LineChart from '../charts/LineChart.svelte';
3
+
4
+ const meta = {
5
+ title: 'Charts/LineChart',
6
+ component: LineChart,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ height: { control: { type: 'number', min: 100, max: 500 } },
10
+ showDots: { control: 'boolean' },
11
+ fill: { control: 'boolean' },
12
+ },
13
+ } satisfies Meta<LineChart>;
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ export const Default: Story = {
19
+ args: {
20
+ data: [
21
+ { label: 'Mon', value: 120 },
22
+ { label: 'Tue', value: 180 },
23
+ { label: 'Wed', value: 150 },
24
+ { label: 'Thu', value: 210 },
25
+ { label: 'Fri', value: 190 },
26
+ { label: 'Sat', value: 250 },
27
+ { label: 'Sun', value: 220 },
28
+ ],
29
+ height: 200,
30
+ showDots: true,
31
+ fill: true,
32
+ },
33
+ };
34
+
35
+ export const NoFill: Story = {
36
+ args: {
37
+ data: [
38
+ { label: 'Q1', value: 3200 },
39
+ { label: 'Q2', value: 4100 },
40
+ { label: 'Q3', value: 3800 },
41
+ { label: 'Q4', value: 5200 },
42
+ ],
43
+ height: 250,
44
+ showDots: true,
45
+ fill: false,
46
+ color: '#10b981',
47
+ },
48
+ };