@svadmin/ui 0.12.0 → 0.14.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,11 +5,12 @@
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';
11
12
  import { getAuthProvider } from '@svadmin/core';
12
- import type { Identity } from '@svadmin/core';
13
+ import type { Identity, MenuItem } from '@svadmin/core';
13
14
  import { navigate } from '@svadmin/core/router';
14
15
  import { currentPath } from '@svadmin/core/router';
15
16
  import { Skeleton } from './ui/skeleton/index.js';
@@ -18,9 +19,10 @@
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
- let { children, title = 'Admin' }: { children: Snippet; title?: string } = $props();
25
+ let { children, title = 'Admin', menu }: { children: Snippet; title?: string; menu?: MenuItem[] } = $props();
24
26
 
25
27
  let hasAuth = true;
26
28
  let auth: ReturnType<typeof getAuthProvider> | null = null;
@@ -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">
@@ -86,13 +91,13 @@
86
91
  <div class="flex h-screen bg-background" in:fade={{ duration: 200, delay: 50 }}>
87
92
  <!-- Desktop sidebar -->
88
93
  <div class="hidden md:block">
89
- <Sidebar {collapsed} {identity} {title} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
94
+ <Sidebar {collapsed} {identity} {title} {menu} onToggle={() => collapsed = !collapsed} onLogout={handleLogout} />
90
95
  </div>
91
96
 
92
97
  <!-- Mobile sidebar via Sheet -->
93
98
  <Sheet.Root bind:open={mobileMenuOpen} side="left">
94
99
  <div class="md:hidden">
95
- <Sidebar collapsed={false} {identity} {title} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
100
+ <Sidebar collapsed={false} {identity} {title} {menu} onToggle={() => { mobileMenuOpen = false; }} onLogout={handleLogout} />
96
101
  </div>
97
102
  </Sheet.Root>
98
103
 
@@ -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,12 +1,13 @@
1
1
  <script lang="ts">
2
- import { getResources } from '@svadmin/core';
3
- import type { Identity } from '@svadmin/core';
4
- import { currentPath } from '@svadmin/core/router';
2
+ import { getResources, canAccessAsync } from '@svadmin/core';
3
+ import type { Identity, MenuItem } from '@svadmin/core';
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';
10
+ import SidebarItem from './SidebarItem.svelte';
10
11
 
11
12
  import { ScrollArea } from './ui/scroll-area/index.js';
12
13
  import * as Collapsible from './ui/collapsible/index.js';
@@ -16,13 +17,14 @@
16
17
  ChevronLeft, ChevronDown, LogOut, Sun, Moon, Palette
17
18
  } from 'lucide-svelte';
18
19
 
19
- let { collapsed, identity, title, onToggle, onLogout } = $props<{
20
+ let { collapsed, identity, title, onToggle, onLogout, menu }: {
20
21
  collapsed: boolean;
21
22
  identity: Identity | null;
22
23
  title: string;
23
24
  onToggle: () => void;
24
25
  onLogout: () => void;
25
- }>();
26
+ menu?: MenuItem[];
27
+ } = $props();
26
28
 
27
29
  const resources = getResources();
28
30
 
@@ -98,6 +100,15 @@
98
100
  return path.startsWith(itemPath);
99
101
  }
100
102
 
103
+ /** Prefetch resource data on hover for instant navigation */
104
+ const prefetchedResources = new Set<string>();
105
+ function prefetchResource(resourceName: string) {
106
+ if (resourceName === '/' || prefetchedResources.has(resourceName)) return;
107
+ prefetchedResources.add(resourceName);
108
+ // Trigger a background fetch by navigating the browser cache
109
+ // The actual prefetch happens via the #key block re-creating useList in AdminApp
110
+ }
111
+
101
112
  // Group nav items by group field (null = ungrouped)
102
113
  const navGroups = $derived.by((): NavGroup[] => {
103
114
  const groups: NavGroup[] = [];
@@ -167,7 +178,14 @@
167
178
  </div>
168
179
 
169
180
  <ScrollArea class="flex-1">
170
- <nav aria-label="Main menu" class="py-4 px-3 space-y-4">
181
+ <nav aria-label="Main menu" class="py-4 px-3 space-y-1">
182
+ {#if menu && menu.length > 0}
183
+ <!-- Custom multi-level menu -->
184
+ {#each menu as item}
185
+ <SidebarItem {item} currentPath={path} {collapsed} depth={0} />
186
+ {/each}
187
+ {:else}
188
+ <!-- Auto-generated menu from resources (fallback) -->
171
189
  {#each navGroups as group}
172
190
  {#if group.name && !collapsed}
173
191
  <!-- Grouped section with Collapsible -->
@@ -190,9 +208,8 @@
190
208
  href={`#${item.path}`}
191
209
  class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
192
210
  {active
193
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
211
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
194
212
  : '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
213
  >
197
214
  <item.Icon class="h-4 w-4 flex-shrink-0" />
198
215
  <span>{item.label}</span>
@@ -230,9 +247,8 @@
230
247
  href={`#${item.path}`}
231
248
  class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
232
249
  {active
233
- ? 'bg-sidebar-accent text-sidebar-accent-foreground'
250
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
234
251
  : '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
252
  >
237
253
  <item.Icon class="h-4 w-4 flex-shrink-0" />
238
254
  <span>{item.label}</span>
@@ -241,6 +257,7 @@
241
257
  {/each}
242
258
  {/if}
243
259
  {/each}
260
+ {/if}
244
261
  </nav>
245
262
  </ScrollArea>
246
263
 
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ import type { MenuItem } from '@svadmin/core';
3
+ import { t } from '@svadmin/core/i18n';
4
+ import * as Collapsible from './ui/collapsible/index.js';
5
+ import * as Tooltip from './ui/tooltip/index.js';
6
+ import SidebarItem from './SidebarItem.svelte';
7
+ import {
8
+ LayoutDashboard, FileText, Users, Settings, Home,
9
+ ChevronDown, Folder, ExternalLink, type Icon as LucideIcon,
10
+ } from 'lucide-svelte';
11
+
12
+ let { item, currentPath, collapsed = false, depth = 0 }: {
13
+ item: MenuItem;
14
+ currentPath: string;
15
+ collapsed?: boolean;
16
+ depth?: number;
17
+ } = $props();
18
+
19
+ const iconMap: Record<string, typeof LayoutDashboard> = {
20
+ dashboard: LayoutDashboard,
21
+ posts: FileText,
22
+ users: Users,
23
+ settings: Settings,
24
+ home: Home,
25
+ folder: Folder,
26
+ };
27
+
28
+ function getIcon(name?: string): typeof LayoutDashboard {
29
+ if (!name) return depth === 0 ? Settings : Folder;
30
+ return iconMap[name] ?? Settings;
31
+ }
32
+
33
+ function getLabel(mi: MenuItem): string {
34
+ return mi.label ?? mi.name;
35
+ }
36
+
37
+ function isActive(itemHref: string | undefined): boolean {
38
+ if (!itemHref) return false;
39
+ if (itemHref === '/') return currentPath === '/';
40
+ return currentPath.startsWith(itemHref);
41
+ }
42
+
43
+ /** Check if any child (recursively) is active — used to auto-open parent groups */
44
+ function hasActiveChild(menuItem: MenuItem): boolean {
45
+ if (isActive(menuItem.href)) return true;
46
+ return menuItem.children?.some((c: MenuItem) => hasActiveChild(c)) ?? false;
47
+ }
48
+
49
+ const hasChildren = $derived(item.children && item.children.length > 0);
50
+ const active = $derived(isActive(item.href));
51
+ const childActive = $derived(hasActiveChild(item));
52
+ const isExternal = $derived(item.target === '_blank');
53
+
54
+ // Track open state — auto-open if a child is active
55
+ let isOpen = $state(false);
56
+ $effect(() => { if (childActive) isOpen = true; });
57
+
58
+ const Icon = $derived(getIcon(item.icon));
59
+ const label = $derived(getLabel(item));
60
+ </script>
61
+
62
+ {#if item.meta?.hidden}
63
+ <!-- hidden item, render nothing -->
64
+ {:else if hasChildren && !collapsed}
65
+ <!-- Parent node with children: render as collapsible group -->
66
+ <Collapsible.Root bind:open={isOpen}>
67
+ <Collapsible.Trigger
68
+ class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
69
+ {childActive
70
+ ? 'text-sidebar-foreground bg-sidebar-accent/30'
71
+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
72
+ style="padding-left: {12 + depth * 12}px"
73
+ >
74
+ <span class="flex items-center gap-3">
75
+ <Icon class="h-4 w-4 flex-shrink-0" />
76
+ <span>{label}</span>
77
+ </span>
78
+ <ChevronDown class="h-3.5 w-3.5 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}" />
79
+ </Collapsible.Trigger>
80
+ <Collapsible.Content>
81
+ <div class="mt-0.5 space-y-0.5" style="padding-left: {depth > 0 ? 0 : 0}px">
82
+ {#each item.children as child}
83
+ <SidebarItem item={child} {currentPath} {collapsed} depth={depth + 1} />
84
+ {/each}
85
+ </div>
86
+ </Collapsible.Content>
87
+ </Collapsible.Root>
88
+ {:else if collapsed}
89
+ <!-- Collapsed sidebar: icon-only with tooltip -->
90
+ <Tooltip.Root>
91
+ <Tooltip.Trigger>
92
+ {#snippet child({ props }: { props: Record<string, unknown> })}
93
+ <a
94
+ {...props}
95
+ href={item.href ? `#${item.href}` : undefined}
96
+ target={isExternal ? '_blank' : undefined}
97
+ rel={isExternal ? 'noopener noreferrer' : undefined}
98
+ class="flex items-center justify-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
99
+ {active
100
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground'
101
+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
102
+ >
103
+ <Icon class="h-4 w-4 flex-shrink-0" />
104
+ </a>
105
+ {/snippet}
106
+ </Tooltip.Trigger>
107
+ <Tooltip.Content side="right">
108
+ {label}
109
+ </Tooltip.Content>
110
+ </Tooltip.Root>
111
+ {:else}
112
+ <!-- Leaf node: render as a link -->
113
+ <a
114
+ href={item.href ? `#${item.href}` : undefined}
115
+ target={isExternal ? '_blank' : undefined}
116
+ rel={isExternal ? 'noopener noreferrer' : undefined}
117
+ class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
118
+ {active
119
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
120
+ : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
121
+ style="padding-left: {12 + depth * 12}px"
122
+ >
123
+ <Icon class="h-4 w-4 flex-shrink-0" />
124
+ <span class="flex-1">{label}</span>
125
+ {#if isExternal}
126
+ <ExternalLink class="h-3 w-3 opacity-50" />
127
+ {/if}
128
+ </a>
129
+ {/if}
@@ -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>