@opendata-ai/openchart-svelte 2.0.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.
Files changed (66) hide show
  1. package/README.md +94 -0
  2. package/dist/Chart.svelte +132 -0
  3. package/dist/Chart.svelte.d.ts +20 -0
  4. package/dist/Chart.svelte.d.ts.map +1 -0
  5. package/dist/DataTable.svelte +132 -0
  6. package/dist/DataTable.svelte.d.ts +19 -0
  7. package/dist/DataTable.svelte.d.ts.map +1 -0
  8. package/dist/Graph.svelte +119 -0
  9. package/dist/Graph.svelte.d.ts +22 -0
  10. package/dist/Graph.svelte.d.ts.map +1 -0
  11. package/dist/ThemeProvider.svelte +27 -0
  12. package/dist/ThemeProvider.svelte.d.ts +11 -0
  13. package/dist/ThemeProvider.svelte.d.ts.map +1 -0
  14. package/dist/Visualization.svelte +35 -0
  15. package/dist/Visualization.svelte.d.ts +12 -0
  16. package/dist/Visualization.svelte.d.ts.map +1 -0
  17. package/dist/__tests__/Chart.test.js +108 -0
  18. package/dist/__tests__/DataTable.test.js +101 -0
  19. package/dist/__tests__/Graph.test.js +104 -0
  20. package/dist/__tests__/ThemeContext.test.js +89 -0
  21. package/dist/__tests__/composables.test.js +84 -0
  22. package/dist/__tests__/useTableState.test.js +105 -0
  23. package/dist/composables/useChart.svelte.d.ts +41 -0
  24. package/dist/composables/useChart.svelte.d.ts.map +1 -0
  25. package/dist/composables/useChart.svelte.js +73 -0
  26. package/dist/composables/useDarkMode.svelte.d.ts +15 -0
  27. package/dist/composables/useDarkMode.svelte.d.ts.map +1 -0
  28. package/dist/composables/useDarkMode.svelte.js +47 -0
  29. package/dist/composables/useGraph.svelte.d.ts +53 -0
  30. package/dist/composables/useGraph.svelte.d.ts.map +1 -0
  31. package/dist/composables/useGraph.svelte.js +67 -0
  32. package/dist/composables/useTable.svelte.d.ts +30 -0
  33. package/dist/composables/useTable.svelte.d.ts.map +1 -0
  34. package/dist/composables/useTable.svelte.js +59 -0
  35. package/dist/composables/useTableState.svelte.d.ts +45 -0
  36. package/dist/composables/useTableState.svelte.d.ts.map +1 -0
  37. package/dist/composables/useTableState.svelte.js +58 -0
  38. package/dist/context.d.ts +21 -0
  39. package/dist/context.d.ts.map +1 -0
  40. package/dist/context.js +28 -0
  41. package/dist/index.d.ts +25 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +21 -0
  44. package/dist/types.d.ts +60 -0
  45. package/dist/types.d.ts.map +1 -0
  46. package/dist/types.js +7 -0
  47. package/package.json +65 -0
  48. package/src/Chart.svelte +132 -0
  49. package/src/DataTable.svelte +132 -0
  50. package/src/Graph.svelte +119 -0
  51. package/src/ThemeProvider.svelte +27 -0
  52. package/src/Visualization.svelte +35 -0
  53. package/src/__tests__/Chart.test.ts +134 -0
  54. package/src/__tests__/DataTable.test.ts +126 -0
  55. package/src/__tests__/Graph.test.ts +130 -0
  56. package/src/__tests__/ThemeContext.test.ts +106 -0
  57. package/src/__tests__/composables.test.ts +101 -0
  58. package/src/__tests__/useTableState.test.ts +134 -0
  59. package/src/composables/useChart.svelte.ts +108 -0
  60. package/src/composables/useDarkMode.svelte.ts +51 -0
  61. package/src/composables/useGraph.svelte.ts +115 -0
  62. package/src/composables/useTable.svelte.ts +85 -0
  63. package/src/composables/useTableState.svelte.ts +78 -0
  64. package/src/context.ts +35 -0
  65. package/src/index.ts +46 -0
  66. package/src/types.ts +78 -0
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@opendata-ai/openchart-svelte",
3
+ "version": "2.0.0",
4
+ "description": "Svelte components for openchart: <Chart />, <DataTable />, <Graph />, <VizThemeProvider />",
5
+ "license": "Apache-2.0",
6
+ "author": "Riley Hilliard",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/tryopendata/openchart.git",
10
+ "directory": "packages/svelte"
11
+ },
12
+ "homepage": "https://github.com/tryopendata/openchart#readme",
13
+ "bugs": "https://github.com/tryopendata/openchart/issues",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "type": "module",
21
+ "main": "dist/index.js",
22
+ "types": "dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "svelte": "./dist/index.js",
27
+ "import": "./dist/index.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src"
33
+ ],
34
+ "svelte": "./dist/index.js",
35
+ "sideEffects": false,
36
+ "keywords": [
37
+ "chart",
38
+ "visualization",
39
+ "svelte",
40
+ "data-table",
41
+ "svg",
42
+ "d3",
43
+ "declarative"
44
+ ],
45
+ "scripts": {
46
+ "build": "svelte-package -i src -o dist",
47
+ "test": "vitest run",
48
+ "typecheck": "svelte-check --tsconfig ./tsconfig.json"
49
+ },
50
+ "dependencies": {
51
+ "@opendata-ai/openchart-core": "2.0.0",
52
+ "@opendata-ai/openchart-engine": "2.0.0",
53
+ "@opendata-ai/openchart-vanilla": "2.0.0"
54
+ },
55
+ "peerDependencies": {
56
+ "svelte": ">=5.0.0"
57
+ },
58
+ "devDependencies": {
59
+ "@sveltejs/package": "^2.3.0",
60
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
61
+ "@testing-library/svelte": "^5.2.0",
62
+ "svelte": "^5.0.0",
63
+ "svelte-check": "^4.0.0"
64
+ }
65
+ }
@@ -0,0 +1,132 @@
1
+ <!--
2
+ Chart component: Svelte 5 wrapper around the vanilla adapter.
3
+
4
+ Mounts a chart instance on render, updates when spec/options change,
5
+ and cleans up on unmount. All heavy lifting is done by the vanilla
6
+ createChart() function.
7
+ -->
8
+ <script lang="ts">
9
+ import type {
10
+ Annotation,
11
+ AnnotationOffset,
12
+ ChartSpec,
13
+ DarkMode,
14
+ ElementEdit,
15
+ GraphSpec,
16
+ MarkEvent,
17
+ TextAnnotation,
18
+ ThemeConfig,
19
+ } from '@opendata-ai/openchart-core';
20
+ import { type ChartInstance, createChart, type MountOptions } from '@opendata-ai/openchart-vanilla';
21
+ import { onMount, untrack } from 'svelte';
22
+ import { getVizDarkMode, getVizTheme } from './context.js';
23
+
24
+ let {
25
+ spec,
26
+ theme,
27
+ darkMode,
28
+ onmarkclick,
29
+ onmarkhover,
30
+ onmarkleave,
31
+ onlegendtoggle,
32
+ onannotationclick,
33
+ onannotationedit,
34
+ onedit,
35
+ ondatapointclick,
36
+ class: className,
37
+ style,
38
+ }: {
39
+ spec: ChartSpec | GraphSpec;
40
+ theme?: ThemeConfig;
41
+ darkMode?: DarkMode;
42
+ onmarkclick?: (event: MarkEvent) => void;
43
+ onmarkhover?: (event: MarkEvent) => void;
44
+ onmarkleave?: () => void;
45
+ onlegendtoggle?: (series: string, visible: boolean) => void;
46
+ onannotationclick?: (annotation: Annotation, event: MouseEvent) => void;
47
+ onannotationedit?: (annotation: TextAnnotation, offset: AnnotationOffset) => void;
48
+ onedit?: (edit: ElementEdit) => void;
49
+ ondatapointclick?: (data: Record<string, unknown>) => void;
50
+ class?: string;
51
+ style?: string;
52
+ } = $props();
53
+
54
+ let containerEl: HTMLDivElement;
55
+ let instance: ChartInstance | null = null;
56
+
57
+ const ctxTheme = getVizTheme();
58
+ const ctxDarkMode = getVizDarkMode();
59
+
60
+ onMount(() => {
61
+ return () => {
62
+ instance?.destroy();
63
+ instance = null;
64
+ };
65
+ });
66
+
67
+ // Stable callback wrappers that read current handler props without
68
+ // creating reactive dependencies. This prevents callback prop changes
69
+ // from triggering a full chart destroy/recreate cycle.
70
+ const stableHandlers: MountOptions = {
71
+ onMarkClick: (event: MarkEvent) => untrack(() => onmarkclick)?.(event),
72
+ onMarkHover: (event: MarkEvent) => untrack(() => onmarkhover)?.(event),
73
+ onMarkLeave: () => untrack(() => onmarkleave)?.(),
74
+ onLegendToggle: (series: string, visible: boolean) =>
75
+ untrack(() => onlegendtoggle)?.(series, visible),
76
+ onAnnotationClick: (annotation: Annotation, event: MouseEvent) =>
77
+ untrack(() => onannotationclick)?.(annotation, event),
78
+ onDataPointClick: (data: Record<string, unknown>) => untrack(() => ondatapointclick)?.(data),
79
+ };
80
+
81
+ // Editing callbacks - only defined as stable wrappers, but only
82
+ // included in options when the consumer provides the prop.
83
+ const stableOnAnnotationEdit = (annotation: TextAnnotation, offset: AnnotationOffset) =>
84
+ untrack(() => onannotationedit)?.(annotation, offset);
85
+ const stableOnEdit = (edit: ElementEdit) => untrack(() => onedit)?.(edit);
86
+
87
+ let prevSpec = '';
88
+
89
+ // Effect 1: Mount/recreate chart on theme/darkMode changes.
90
+ // Reads spec via untrack() so spec changes don't trigger full recreate.
91
+ $effect(() => {
92
+ const resolvedTheme = theme ?? ctxTheme?.();
93
+ const resolvedDarkMode = darkMode ?? ctxDarkMode?.();
94
+ // Read spec without tracking - spec changes handled in Effect 2
95
+ const currentSpec = untrack(() => spec);
96
+
97
+ instance?.destroy();
98
+
99
+ const hasAnnotationEdit = untrack(() => onannotationedit) !== undefined;
100
+ const hasEdit = untrack(() => onedit) !== undefined;
101
+
102
+ const options: MountOptions = {
103
+ theme: resolvedTheme,
104
+ darkMode: resolvedDarkMode,
105
+ responsive: true,
106
+ ...stableHandlers,
107
+ ...(hasAnnotationEdit ? { onAnnotationEdit: stableOnAnnotationEdit } : {}),
108
+ ...(hasEdit ? { onEdit: stableOnEdit } : {}),
109
+ };
110
+
111
+ instance = createChart(containerEl, currentSpec, options);
112
+ prevSpec = JSON.stringify(currentSpec);
113
+ });
114
+
115
+ // Effect 2: Update chart when spec changes (no destroy/recreate).
116
+ $effect(() => {
117
+ const currentSpec = spec;
118
+ if (!instance) return;
119
+
120
+ const specString = JSON.stringify(currentSpec);
121
+ if (specString !== prevSpec) {
122
+ prevSpec = specString;
123
+ instance.update(currentSpec);
124
+ }
125
+ });
126
+ </script>
127
+
128
+ <div
129
+ bind:this={containerEl}
130
+ class={className ? `viz-chart-root ${className}` : 'viz-chart-root'}
131
+ {style}
132
+ ></div>
@@ -0,0 +1,132 @@
1
+ <!--
2
+ DataTable component: Svelte 5 wrapper around the vanilla table adapter.
3
+
4
+ Mounts a table instance on render, updates when spec changes,
5
+ and cleans up on unmount. Supports both controlled and uncontrolled modes
6
+ for sort, search, and pagination state.
7
+ -->
8
+ <script lang="ts">
9
+ import type { DarkMode, SortState, TableSpec, ThemeConfig } from '@opendata-ai/openchart-core';
10
+ import {
11
+ createTable,
12
+ type TableInstance,
13
+ type TableMountOptions,
14
+ } from '@opendata-ai/openchart-vanilla';
15
+ import { onMount, untrack } from 'svelte';
16
+ import { getVizDarkMode, getVizTheme } from './context.js';
17
+
18
+ let {
19
+ spec,
20
+ theme,
21
+ darkMode,
22
+ onrowclick,
23
+ onsortchange,
24
+ onsearchchange,
25
+ onpagechange,
26
+ sort,
27
+ search,
28
+ page,
29
+ class: className,
30
+ style,
31
+ }: {
32
+ spec: TableSpec;
33
+ theme?: ThemeConfig;
34
+ darkMode?: DarkMode;
35
+ onrowclick?: (row: Record<string, unknown>) => void;
36
+ onsortchange?: (sort: SortState | null) => void;
37
+ onsearchchange?: (query: string) => void;
38
+ onpagechange?: (page: number) => void;
39
+ sort?: SortState | null;
40
+ search?: string;
41
+ page?: number;
42
+ class?: string;
43
+ style?: string;
44
+ } = $props();
45
+
46
+ let containerEl: HTMLDivElement;
47
+ let instance: TableInstance | null = null;
48
+
49
+ const ctxTheme = getVizTheme();
50
+ const ctxDarkMode = getVizDarkMode();
51
+
52
+ const isControlled = $derived(sort !== undefined || search !== undefined || page !== undefined);
53
+
54
+ onMount(() => {
55
+ return () => {
56
+ instance?.destroy();
57
+ instance = null;
58
+ };
59
+ });
60
+
61
+ let prevSpec = '';
62
+
63
+ // Effect 1: Mount/recreate table on theme/darkMode changes.
64
+ $effect(() => {
65
+ const resolvedTheme = theme ?? ctxTheme?.();
66
+ const resolvedDarkMode = darkMode ?? ctxDarkMode?.();
67
+ // Read spec and controlled state without tracking
68
+ const currentSpec = untrack(() => spec);
69
+ const currentIsControlled = untrack(() => isControlled);
70
+ const currentSort = untrack(() => sort);
71
+ const currentSearch = untrack(() => search);
72
+ const currentPage = untrack(() => page);
73
+
74
+ instance?.destroy();
75
+
76
+ const mountOptions: TableMountOptions = {
77
+ theme: resolvedTheme,
78
+ darkMode: resolvedDarkMode,
79
+ onRowClick: (row: Record<string, unknown>) => untrack(() => onrowclick)?.(row),
80
+ responsive: true,
81
+ onStateChange: (state) => {
82
+ if (state.sort !== undefined) untrack(() => onsortchange)?.(state.sort);
83
+ if (state.search !== undefined) untrack(() => onsearchchange)?.(state.search);
84
+ if (state.page !== undefined) untrack(() => onpagechange)?.(state.page);
85
+ },
86
+ };
87
+
88
+ if (currentIsControlled) {
89
+ mountOptions.externalState = {
90
+ sort: currentSort ?? null,
91
+ search: currentSearch ?? '',
92
+ page: currentPage ?? 0,
93
+ };
94
+ }
95
+
96
+ instance = createTable(containerEl, currentSpec, mountOptions);
97
+ prevSpec = JSON.stringify(currentSpec);
98
+ });
99
+
100
+ // Effect 2: Update table when spec changes (no destroy/recreate).
101
+ $effect(() => {
102
+ const currentSpec = spec;
103
+ if (!instance) return;
104
+
105
+ const specString = JSON.stringify(currentSpec);
106
+ if (specString !== prevSpec) {
107
+ prevSpec = specString;
108
+ instance.update(currentSpec);
109
+ }
110
+ });
111
+
112
+ // Effect 3: Sync controlled state without remounting.
113
+ $effect(() => {
114
+ const currentIsControlled = isControlled;
115
+ const currentSort = sort;
116
+ const currentSearch = search;
117
+ const currentPage = page;
118
+ if (!instance || !currentIsControlled) return;
119
+
120
+ instance.setState({
121
+ sort: currentSort ?? null,
122
+ search: currentSearch ?? '',
123
+ page: currentPage ?? 0,
124
+ });
125
+ });
126
+ </script>
127
+
128
+ <div
129
+ bind:this={containerEl}
130
+ class={className ? `viz-table-root ${className}` : 'viz-table-root'}
131
+ {style}
132
+ ></div>
@@ -0,0 +1,119 @@
1
+ <!--
2
+ Graph component: Svelte 5 wrapper around the vanilla adapter.
3
+
4
+ Mounts a graph instance on render, updates when spec changes,
5
+ and cleans up on unmount. All heavy lifting is done by the vanilla
6
+ createGraph() function.
7
+
8
+ Exposes imperative methods via component exports for programmatic
9
+ control (search, zoom, select).
10
+ -->
11
+ <script lang="ts">
12
+ import type { DarkMode, GraphSpec, ThemeConfig } from '@opendata-ai/openchart-core';
13
+ import {
14
+ createGraph,
15
+ type GraphInstance,
16
+ type GraphMountOptions,
17
+ } from '@opendata-ai/openchart-vanilla';
18
+ import { onMount, untrack } from 'svelte';
19
+ import { getVizDarkMode, getVizTheme } from './context.js';
20
+
21
+ let {
22
+ spec,
23
+ theme,
24
+ darkMode,
25
+ onnodeclick,
26
+ onnodedoubleclick,
27
+ onselectionchange,
28
+ class: className,
29
+ style,
30
+ }: {
31
+ spec: GraphSpec;
32
+ theme?: ThemeConfig;
33
+ darkMode?: DarkMode;
34
+ onnodeclick?: (node: Record<string, unknown>) => void;
35
+ onnodedoubleclick?: (node: Record<string, unknown>) => void;
36
+ onselectionchange?: (nodeIds: string[]) => void;
37
+ class?: string;
38
+ style?: string;
39
+ } = $props();
40
+
41
+ let containerEl: HTMLDivElement;
42
+ let instance: GraphInstance | null = null;
43
+
44
+ const ctxTheme = getVizTheme();
45
+ const ctxDarkMode = getVizDarkMode();
46
+
47
+ onMount(() => {
48
+ return () => {
49
+ instance?.destroy();
50
+ instance = null;
51
+ };
52
+ });
53
+
54
+ let prevSpec = '';
55
+
56
+ // Effect 1: Mount/recreate graph on theme/darkMode changes.
57
+ $effect(() => {
58
+ const resolvedTheme = theme ?? ctxTheme?.();
59
+ const resolvedDarkMode = darkMode ?? ctxDarkMode?.();
60
+ const currentSpec = untrack(() => spec);
61
+
62
+ instance?.destroy();
63
+
64
+ const options: GraphMountOptions = {
65
+ theme: resolvedTheme,
66
+ darkMode: resolvedDarkMode,
67
+ onNodeClick: (node: Record<string, unknown>) => untrack(() => onnodeclick)?.(node),
68
+ onNodeDoubleClick: (node: Record<string, unknown>) => untrack(() => onnodedoubleclick)?.(node),
69
+ onSelectionChange: (nodeIds: string[]) => untrack(() => onselectionchange)?.(nodeIds),
70
+ responsive: true,
71
+ };
72
+
73
+ instance = createGraph(containerEl, currentSpec, options);
74
+ prevSpec = JSON.stringify(currentSpec);
75
+ });
76
+
77
+ // Effect 2: Update graph when spec changes (no destroy/recreate).
78
+ $effect(() => {
79
+ const currentSpec = spec;
80
+ if (!instance) return;
81
+
82
+ const specString = JSON.stringify(currentSpec);
83
+ if (specString !== prevSpec) {
84
+ prevSpec = specString;
85
+ instance.update(currentSpec);
86
+ }
87
+ });
88
+
89
+ // Imperative methods exposed via component exports
90
+ export function search(query: string): void {
91
+ instance?.search(query);
92
+ }
93
+
94
+ export function clearSearch(): void {
95
+ instance?.clearSearch();
96
+ }
97
+
98
+ export function zoomToFit(): void {
99
+ instance?.zoomToFit();
100
+ }
101
+
102
+ export function zoomToNode(nodeId: string): void {
103
+ instance?.zoomToNode(nodeId);
104
+ }
105
+
106
+ export function selectNode(nodeId: string): void {
107
+ instance?.selectNode(nodeId);
108
+ }
109
+
110
+ export function getSelectedNodes(): string[] {
111
+ return instance?.getSelectedNodes() ?? [];
112
+ }
113
+ </script>
114
+
115
+ <div
116
+ bind:this={containerEl}
117
+ class={className ? `viz-graph-root ${className}` : 'viz-graph-root'}
118
+ {style}
119
+ ></div>
@@ -0,0 +1,27 @@
1
+ <!--
2
+ VizThemeProvider: provides a theme and dark mode preference to all
3
+ descendant Chart, DataTable, and Graph components.
4
+
5
+ Components use the context values as fallbacks when no explicit
6
+ `theme` or `darkMode` prop is passed.
7
+ -->
8
+ <script lang="ts">
9
+ import type { DarkMode, ThemeConfig } from '@opendata-ai/openchart-core';
10
+ import type { Snippet } from 'svelte';
11
+ import { setVizDarkMode, setVizTheme } from './context.js';
12
+
13
+ let {
14
+ theme,
15
+ darkMode,
16
+ children,
17
+ }: {
18
+ theme: ThemeConfig | undefined;
19
+ darkMode?: DarkMode;
20
+ children: Snippet;
21
+ } = $props();
22
+
23
+ setVizTheme(() => theme);
24
+ setVizDarkMode(() => darkMode);
25
+ </script>
26
+
27
+ {@render children()}
@@ -0,0 +1,35 @@
1
+ <!--
2
+ Visualization routing component: renders Chart, DataTable, or Graph
3
+ based on the spec type. Use this when rendering arbitrary VizSpec values.
4
+
5
+ For event handlers, use the specific component (Chart, DataTable, Graph) directly.
6
+ -->
7
+ <script lang="ts">
8
+ import type { DarkMode, ThemeConfig, VizSpec } from '@opendata-ai/openchart-core';
9
+ import { isGraphSpec, isTableSpec } from '@opendata-ai/openchart-core';
10
+ import Chart from './Chart.svelte';
11
+ import DataTable from './DataTable.svelte';
12
+ import Graph from './Graph.svelte';
13
+
14
+ let {
15
+ spec,
16
+ theme,
17
+ darkMode,
18
+ class: className,
19
+ style,
20
+ }: {
21
+ spec: VizSpec;
22
+ theme?: ThemeConfig;
23
+ darkMode?: DarkMode;
24
+ class?: string;
25
+ style?: string;
26
+ } = $props();
27
+ </script>
28
+
29
+ {#if isTableSpec(spec)}
30
+ <DataTable {spec} {theme} {darkMode} class={className} {style} />
31
+ {:else if isGraphSpec(spec)}
32
+ <Graph {spec} {theme} {darkMode} class={className} {style} />
33
+ {:else}
34
+ <Chart {spec} {theme} {darkMode} class={className} {style} />
35
+ {/if}
@@ -0,0 +1,134 @@
1
+ import type { ChartSpec } from '@opendata-ai/openchart-core';
2
+ import { cleanup, render, waitFor } from '@testing-library/svelte';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import Chart from '../Chart.svelte';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Test data
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const lineSpec: ChartSpec = {
11
+ type: 'line',
12
+ data: [
13
+ { date: '2020-01-01', value: 10, country: 'US' },
14
+ { date: '2021-01-01', value: 40, country: 'US' },
15
+ { date: '2020-01-01', value: 15, country: 'UK' },
16
+ { date: '2021-01-01', value: 35, country: 'UK' },
17
+ ],
18
+ encoding: {
19
+ x: { field: 'date', type: 'temporal' },
20
+ y: { field: 'value', type: 'quantitative' },
21
+ color: { field: 'country', type: 'nominal' },
22
+ },
23
+ chrome: {
24
+ title: 'GDP Growth',
25
+ subtitle: 'US vs UK over time',
26
+ source: 'World Bank',
27
+ },
28
+ };
29
+
30
+ const barSpec: ChartSpec = {
31
+ type: 'bar',
32
+ data: [
33
+ { name: 'A', value: 10 },
34
+ { name: 'B', value: 30 },
35
+ { name: 'C', value: 20 },
36
+ ],
37
+ encoding: {
38
+ x: { field: 'value', type: 'quantitative' },
39
+ y: { field: 'name', type: 'nominal' },
40
+ },
41
+ chrome: {
42
+ title: 'Updated Title',
43
+ },
44
+ };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helper: render Chart and wait for SVG to appear ($effect is deferred)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ async function renderChart(props: { spec: ChartSpec; [key: string]: unknown }) {
51
+ const result = render(Chart, { props });
52
+ await waitFor(() => {
53
+ expect(result.container.querySelector('svg')).not.toBeNull();
54
+ });
55
+ return result;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Tests
60
+ // ---------------------------------------------------------------------------
61
+
62
+ afterEach(() => {
63
+ cleanup();
64
+ });
65
+
66
+ describe('<Chart />', () => {
67
+ it('renders an SVG element', async () => {
68
+ const { container } = await renderChart({ spec: lineSpec });
69
+ const svg = container.querySelector('svg');
70
+ expect(svg).not.toBeNull();
71
+ expect(svg?.getAttribute('class')).toBe('viz-chart');
72
+ });
73
+
74
+ it('renders chrome text elements', async () => {
75
+ const { container } = await renderChart({ spec: lineSpec });
76
+
77
+ const title = container.querySelector('.viz-title');
78
+ expect(title).not.toBeNull();
79
+ expect(title?.textContent).toBe('GDP Growth');
80
+
81
+ const subtitle = container.querySelector('.viz-subtitle');
82
+ expect(subtitle?.textContent).toBe('US vs UK over time');
83
+
84
+ const source = container.querySelector('.viz-source');
85
+ expect(source?.textContent).toBe('World Bank');
86
+ });
87
+
88
+ it('spec changes trigger re-render', async () => {
89
+ const { container, rerender } = await renderChart({ spec: lineSpec });
90
+
91
+ const titleBefore = container.querySelector('.viz-title');
92
+ expect(titleBefore?.textContent).toBe('GDP Growth');
93
+
94
+ await rerender({ spec: barSpec });
95
+ await waitFor(() => {
96
+ expect(container.querySelector('.viz-title')?.textContent).toBe('Updated Title');
97
+ });
98
+ });
99
+
100
+ it('unmounting cleans up chart instance', async () => {
101
+ const { container, unmount } = await renderChart({ spec: lineSpec });
102
+
103
+ const svgBefore = container.querySelector('svg');
104
+ expect(svgBefore).not.toBeNull();
105
+
106
+ unmount();
107
+
108
+ expect(container.querySelector('svg')).toBeNull();
109
+ });
110
+
111
+ it('className prop passes through to wrapper div', async () => {
112
+ const { container } = await renderChart({ spec: lineSpec, class: 'my-chart' });
113
+
114
+ const wrapper = container.firstElementChild as HTMLElement;
115
+ expect(wrapper?.className).toContain('my-chart');
116
+ });
117
+
118
+ it('renders with dark mode option', async () => {
119
+ const { container } = await renderChart({ spec: lineSpec, darkMode: 'force' });
120
+
121
+ const svg = container.querySelector('svg');
122
+ expect(svg).not.toBeNull();
123
+ });
124
+
125
+ it('style prop passes through to wrapper div', async () => {
126
+ const { container } = await renderChart({
127
+ spec: lineSpec,
128
+ style: 'border: 1px solid red',
129
+ });
130
+
131
+ const wrapper = container.firstElementChild as HTMLElement;
132
+ expect(wrapper?.style.border).toBe('1px solid red');
133
+ });
134
+ });