@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.
- package/README.md +94 -0
- package/dist/Chart.svelte +132 -0
- package/dist/Chart.svelte.d.ts +20 -0
- package/dist/Chart.svelte.d.ts.map +1 -0
- package/dist/DataTable.svelte +132 -0
- package/dist/DataTable.svelte.d.ts +19 -0
- package/dist/DataTable.svelte.d.ts.map +1 -0
- package/dist/Graph.svelte +119 -0
- package/dist/Graph.svelte.d.ts +22 -0
- package/dist/Graph.svelte.d.ts.map +1 -0
- package/dist/ThemeProvider.svelte +27 -0
- package/dist/ThemeProvider.svelte.d.ts +11 -0
- package/dist/ThemeProvider.svelte.d.ts.map +1 -0
- package/dist/Visualization.svelte +35 -0
- package/dist/Visualization.svelte.d.ts +12 -0
- package/dist/Visualization.svelte.d.ts.map +1 -0
- package/dist/__tests__/Chart.test.js +108 -0
- package/dist/__tests__/DataTable.test.js +101 -0
- package/dist/__tests__/Graph.test.js +104 -0
- package/dist/__tests__/ThemeContext.test.js +89 -0
- package/dist/__tests__/composables.test.js +84 -0
- package/dist/__tests__/useTableState.test.js +105 -0
- package/dist/composables/useChart.svelte.d.ts +41 -0
- package/dist/composables/useChart.svelte.d.ts.map +1 -0
- package/dist/composables/useChart.svelte.js +73 -0
- package/dist/composables/useDarkMode.svelte.d.ts +15 -0
- package/dist/composables/useDarkMode.svelte.d.ts.map +1 -0
- package/dist/composables/useDarkMode.svelte.js +47 -0
- package/dist/composables/useGraph.svelte.d.ts +53 -0
- package/dist/composables/useGraph.svelte.d.ts.map +1 -0
- package/dist/composables/useGraph.svelte.js +67 -0
- package/dist/composables/useTable.svelte.d.ts +30 -0
- package/dist/composables/useTable.svelte.d.ts.map +1 -0
- package/dist/composables/useTable.svelte.js +59 -0
- package/dist/composables/useTableState.svelte.d.ts +45 -0
- package/dist/composables/useTableState.svelte.d.ts.map +1 -0
- package/dist/composables/useTableState.svelte.js +58 -0
- package/dist/context.d.ts +21 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +28 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/package.json +65 -0
- package/src/Chart.svelte +132 -0
- package/src/DataTable.svelte +132 -0
- package/src/Graph.svelte +119 -0
- package/src/ThemeProvider.svelte +27 -0
- package/src/Visualization.svelte +35 -0
- package/src/__tests__/Chart.test.ts +134 -0
- package/src/__tests__/DataTable.test.ts +126 -0
- package/src/__tests__/Graph.test.ts +130 -0
- package/src/__tests__/ThemeContext.test.ts +106 -0
- package/src/__tests__/composables.test.ts +101 -0
- package/src/__tests__/useTableState.test.ts +134 -0
- package/src/composables/useChart.svelte.ts +108 -0
- package/src/composables/useDarkMode.svelte.ts +51 -0
- package/src/composables/useGraph.svelte.ts +115 -0
- package/src/composables/useTable.svelte.ts +85 -0
- package/src/composables/useTableState.svelte.ts +78 -0
- package/src/context.ts +35 -0
- package/src/index.ts +46 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { TableSpec } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { cleanup, render, waitFor } from '@testing-library/svelte';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import DataTable from '../DataTable.svelte';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test data
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const tableSpec: TableSpec = {
|
|
11
|
+
type: 'table',
|
|
12
|
+
data: [
|
|
13
|
+
{ name: 'Alice', age: 30, city: 'Portland' },
|
|
14
|
+
{ name: 'Bob', age: 25, city: 'Seattle' },
|
|
15
|
+
{ name: 'Charlie', age: 35, city: 'Denver' },
|
|
16
|
+
],
|
|
17
|
+
columns: [
|
|
18
|
+
{ key: 'name', label: 'Name' },
|
|
19
|
+
{ key: 'age', label: 'Age' },
|
|
20
|
+
{ key: 'city', label: 'City' },
|
|
21
|
+
],
|
|
22
|
+
chrome: { title: 'People Table' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const updatedSpec: TableSpec = {
|
|
26
|
+
type: 'table',
|
|
27
|
+
data: [
|
|
28
|
+
{ x: 1, y: 2 },
|
|
29
|
+
{ x: 3, y: 4 },
|
|
30
|
+
],
|
|
31
|
+
columns: [
|
|
32
|
+
{ key: 'x', label: 'X Value' },
|
|
33
|
+
{ key: 'y', label: 'Y Value' },
|
|
34
|
+
],
|
|
35
|
+
chrome: { title: 'Updated Table' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Helper: render DataTable and wait for the table to mount ($effect is deferred)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
async function renderTable(props: { spec: TableSpec; [key: string]: unknown }) {
|
|
43
|
+
const result = render(DataTable, { props });
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(result.container.querySelector('table')).not.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Tests
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
cleanup();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('<DataTable />', () => {
|
|
59
|
+
it('renders a table', async () => {
|
|
60
|
+
const { container } = await renderTable({ spec: tableSpec });
|
|
61
|
+
const table = container.querySelector('table');
|
|
62
|
+
expect(table).not.toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('renders correct number of columns', async () => {
|
|
66
|
+
const { container } = await renderTable({ spec: tableSpec });
|
|
67
|
+
const headers = container.querySelectorAll('thead th');
|
|
68
|
+
expect(headers.length).toBe(3);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('renders correct number of rows', async () => {
|
|
72
|
+
const { container } = await renderTable({ spec: tableSpec });
|
|
73
|
+
const rows = container.querySelectorAll('tbody tr');
|
|
74
|
+
expect(rows.length).toBe(3);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('spec changes trigger re-render', async () => {
|
|
78
|
+
const { container, rerender } = await renderTable({ spec: tableSpec });
|
|
79
|
+
|
|
80
|
+
const titleBefore = container.querySelector('.viz-table-title');
|
|
81
|
+
expect(titleBefore?.textContent).toBe('People Table');
|
|
82
|
+
|
|
83
|
+
await rerender({ spec: updatedSpec });
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(container.querySelector('.viz-table-title')?.textContent).toBe('Updated Table');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const headersAfter = container.querySelectorAll('thead th');
|
|
89
|
+
expect(headersAfter.length).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('unmounting cleans up', async () => {
|
|
93
|
+
const { container, unmount } = await renderTable({ spec: tableSpec });
|
|
94
|
+
|
|
95
|
+
const tableBefore = container.querySelector('table');
|
|
96
|
+
expect(tableBefore).not.toBeNull();
|
|
97
|
+
|
|
98
|
+
unmount();
|
|
99
|
+
|
|
100
|
+
expect(container.querySelector('table')).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('className passes through', async () => {
|
|
104
|
+
const { container } = await renderTable({ spec: tableSpec, class: 'my-table' });
|
|
105
|
+
|
|
106
|
+
const wrapper = container.firstElementChild as HTMLElement;
|
|
107
|
+
expect(wrapper?.className).toContain('my-table');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('renders with dark mode option', async () => {
|
|
111
|
+
const { container } = await renderTable({ spec: tableSpec, darkMode: 'force' });
|
|
112
|
+
|
|
113
|
+
const table = container.querySelector('table');
|
|
114
|
+
expect(table).not.toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('style prop passes through to wrapper div', async () => {
|
|
118
|
+
const { container } = await renderTable({
|
|
119
|
+
spec: tableSpec,
|
|
120
|
+
style: 'border: 1px solid red',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const wrapper = container.firstElementChild as HTMLElement;
|
|
124
|
+
expect(wrapper?.style.border).toBe('1px solid red');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { GraphSpec } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { cleanup, render, waitFor } from '@testing-library/svelte';
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import Graph from '../Graph.svelte';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test data
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const basicSpec: GraphSpec = {
|
|
11
|
+
type: 'graph',
|
|
12
|
+
nodes: [
|
|
13
|
+
{ id: 'a', label: 'Node A' },
|
|
14
|
+
{ id: 'b', label: 'Node B' },
|
|
15
|
+
{ id: 'c', label: 'Node C' },
|
|
16
|
+
],
|
|
17
|
+
edges: [
|
|
18
|
+
{ source: 'a', target: 'b' },
|
|
19
|
+
{ source: 'b', target: 'c' },
|
|
20
|
+
],
|
|
21
|
+
chrome: {
|
|
22
|
+
title: 'Test Graph',
|
|
23
|
+
subtitle: 'A simple test graph',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const updatedSpec: GraphSpec = {
|
|
28
|
+
type: 'graph',
|
|
29
|
+
nodes: [
|
|
30
|
+
{ id: 'x', label: 'Node X' },
|
|
31
|
+
{ id: 'y', label: 'Node Y' },
|
|
32
|
+
],
|
|
33
|
+
edges: [{ source: 'x', target: 'y' }],
|
|
34
|
+
chrome: {
|
|
35
|
+
title: 'Updated Graph',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Helper: render Graph and wait for canvas to mount ($effect is deferred)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async function renderGraph(props: { spec: GraphSpec; [key: string]: unknown }) {
|
|
44
|
+
const result = render(Graph, { props });
|
|
45
|
+
await waitFor(() => {
|
|
46
|
+
expect(result.container.querySelector('canvas')).not.toBeNull();
|
|
47
|
+
});
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Tests
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
cleanup();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('<Graph />', () => {
|
|
60
|
+
it('renders a container div', async () => {
|
|
61
|
+
const { container } = await renderGraph({ spec: basicSpec });
|
|
62
|
+
const wrapper = container.firstElementChild as HTMLElement;
|
|
63
|
+
expect(wrapper).not.toBeNull();
|
|
64
|
+
expect(wrapper.tagName.toLowerCase()).toBe('div');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('mounts graph instance with canvas element', async () => {
|
|
68
|
+
const { container } = await renderGraph({ spec: basicSpec });
|
|
69
|
+
const canvas = container.querySelector('canvas');
|
|
70
|
+
expect(canvas).not.toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders chrome text elements', async () => {
|
|
74
|
+
const { container } = await renderGraph({ spec: basicSpec });
|
|
75
|
+
|
|
76
|
+
const title = container.querySelector('.viz-title');
|
|
77
|
+
expect(title).not.toBeNull();
|
|
78
|
+
expect(title?.textContent).toBe('Test Graph');
|
|
79
|
+
|
|
80
|
+
const subtitle = container.querySelector('.viz-subtitle');
|
|
81
|
+
expect(subtitle?.textContent).toBe('A simple test graph');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('spec changes trigger re-render', async () => {
|
|
85
|
+
const { container, rerender } = await renderGraph({ spec: basicSpec });
|
|
86
|
+
|
|
87
|
+
const titleBefore = container.querySelector('.viz-title');
|
|
88
|
+
expect(titleBefore?.textContent).toBe('Test Graph');
|
|
89
|
+
|
|
90
|
+
await rerender({ spec: updatedSpec });
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(container.querySelector('.viz-title')?.textContent).toBe('Updated Graph');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('unmounting cleans up graph instance', async () => {
|
|
97
|
+
const { container, unmount } = await renderGraph({ spec: basicSpec });
|
|
98
|
+
|
|
99
|
+
const canvasBefore = container.querySelector('canvas');
|
|
100
|
+
expect(canvasBefore).not.toBeNull();
|
|
101
|
+
|
|
102
|
+
unmount();
|
|
103
|
+
|
|
104
|
+
expect(container.querySelector('canvas')).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('className prop passes through to wrapper div', async () => {
|
|
108
|
+
const { container } = await renderGraph({ spec: basicSpec, class: 'my-graph' });
|
|
109
|
+
|
|
110
|
+
const wrapper = container.firstElementChild as HTMLElement;
|
|
111
|
+
expect(wrapper?.className).toContain('my-graph');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('style prop passes through to wrapper div', async () => {
|
|
115
|
+
const { container } = await renderGraph({
|
|
116
|
+
spec: basicSpec,
|
|
117
|
+
style: 'border: 1px solid red',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const wrapper = container.firstElementChild as HTMLElement;
|
|
121
|
+
expect(wrapper?.style.border).toBe('1px solid red');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('renders with dark mode option', async () => {
|
|
125
|
+
const { container } = await renderGraph({ spec: basicSpec, darkMode: 'force' });
|
|
126
|
+
|
|
127
|
+
const canvas = container.querySelector('canvas');
|
|
128
|
+
expect(canvas).not.toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for VizThemeProvider context propagation.
|
|
3
|
+
*
|
|
4
|
+
* Tests that theme and dark mode contexts are correctly provided to
|
|
5
|
+
* descendant Chart components via the VizThemeProvider.
|
|
6
|
+
*
|
|
7
|
+
* Since testing context requires Svelte components that consume context,
|
|
8
|
+
* we test through the Chart component which reads context via
|
|
9
|
+
* getVizTheme() and getVizDarkMode().
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ChartSpec, ThemeConfig } from '@opendata-ai/openchart-core';
|
|
13
|
+
import { cleanup, render, waitFor } from '@testing-library/svelte';
|
|
14
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
15
|
+
import Chart from '../Chart.svelte';
|
|
16
|
+
import { DARK_MODE_KEY, THEME_KEY } from '../context.js';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Test data
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const minimalSpec: ChartSpec = {
|
|
23
|
+
type: 'bar',
|
|
24
|
+
data: [
|
|
25
|
+
{ name: 'A', value: 10 },
|
|
26
|
+
{ name: 'B', value: 20 },
|
|
27
|
+
],
|
|
28
|
+
encoding: {
|
|
29
|
+
x: { field: 'value', type: 'quantitative' },
|
|
30
|
+
y: { field: 'name', type: 'nominal' },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Tests
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
cleanup();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('Theme context', () => {
|
|
43
|
+
it('Chart renders without context (graceful fallback)', async () => {
|
|
44
|
+
const { container } = render(Chart, { props: { spec: minimalSpec } });
|
|
45
|
+
await waitFor(() => {
|
|
46
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('Chart receives theme from context', async () => {
|
|
51
|
+
const theme: ThemeConfig = {
|
|
52
|
+
colors: { categorical: ['#ff0000', '#00ff00', '#0000ff'] },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const { container } = render(Chart, {
|
|
56
|
+
props: { spec: minimalSpec },
|
|
57
|
+
context: new Map([[THEME_KEY, () => theme]]),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await waitFor(() => {
|
|
61
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('Chart receives dark mode from context', async () => {
|
|
66
|
+
const { container } = render(Chart, {
|
|
67
|
+
props: { spec: minimalSpec },
|
|
68
|
+
context: new Map([[DARK_MODE_KEY, () => 'force']]),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await waitFor(() => {
|
|
72
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('explicit theme prop overrides context theme', async () => {
|
|
77
|
+
const contextTheme: ThemeConfig = {
|
|
78
|
+
colors: { categorical: ['#111'] },
|
|
79
|
+
};
|
|
80
|
+
const propTheme: ThemeConfig = {
|
|
81
|
+
colors: { categorical: ['#222'] },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// When both context and prop are provided, prop should take priority.
|
|
85
|
+
// We just verify it renders successfully (the override logic is in the component).
|
|
86
|
+
const { container } = render(Chart, {
|
|
87
|
+
props: { spec: minimalSpec, theme: propTheme },
|
|
88
|
+
context: new Map([[THEME_KEY, () => contextTheme]]),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('explicit darkMode prop overrides context darkMode', async () => {
|
|
97
|
+
const { container } = render(Chart, {
|
|
98
|
+
props: { spec: minimalSpec, darkMode: 'off' },
|
|
99
|
+
context: new Map([[DARK_MODE_KEY, () => 'force']]),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useDarkMode composable.
|
|
3
|
+
*
|
|
4
|
+
* Since useDarkMode uses Svelte 5 runes ($state, $effect), it needs to run
|
|
5
|
+
* in a Svelte component context. We test by rendering a Chart component with
|
|
6
|
+
* dark mode options and verifying it renders correctly.
|
|
7
|
+
*
|
|
8
|
+
* Direct unit testing of rune-based composables outside a component context
|
|
9
|
+
* is limited in Svelte 5, so we test the observable behavior through components.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ChartSpec } from '@opendata-ai/openchart-core';
|
|
13
|
+
import { cleanup, render, waitFor } from '@testing-library/svelte';
|
|
14
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import Chart from '../Chart.svelte';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Test data
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const barSpec: ChartSpec = {
|
|
22
|
+
type: 'bar',
|
|
23
|
+
data: [
|
|
24
|
+
{ name: 'A', value: 10 },
|
|
25
|
+
{ name: 'B', value: 30 },
|
|
26
|
+
],
|
|
27
|
+
encoding: {
|
|
28
|
+
x: { field: 'value', type: 'quantitative' },
|
|
29
|
+
y: { field: 'name', type: 'nominal' },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Tests
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
cleanup();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('dark mode rendering', () => {
|
|
42
|
+
it('renders without dark mode', async () => {
|
|
43
|
+
const { container } = render(Chart, { props: { spec: barSpec } });
|
|
44
|
+
await waitFor(() => {
|
|
45
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders with dark mode off', async () => {
|
|
50
|
+
const { container } = render(Chart, {
|
|
51
|
+
props: { spec: barSpec, darkMode: 'off' },
|
|
52
|
+
});
|
|
53
|
+
await waitFor(() => {
|
|
54
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders with dark mode forced', async () => {
|
|
59
|
+
const { container } = render(Chart, {
|
|
60
|
+
props: { spec: barSpec, darkMode: 'force' },
|
|
61
|
+
});
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('renders with dark mode auto', async () => {
|
|
68
|
+
const spy = vi.spyOn(window, 'matchMedia').mockImplementation(
|
|
69
|
+
(query: string) =>
|
|
70
|
+
({
|
|
71
|
+
matches: query === '(prefers-color-scheme: dark)',
|
|
72
|
+
media: query,
|
|
73
|
+
addEventListener: vi.fn(),
|
|
74
|
+
removeEventListener: vi.fn(),
|
|
75
|
+
}) as unknown as MediaQueryList,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const { container } = render(Chart, {
|
|
79
|
+
props: { spec: barSpec, darkMode: 'auto' },
|
|
80
|
+
});
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
spy.mockRestore();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('switching dark mode re-renders chart', async () => {
|
|
89
|
+
const { container, rerender } = render(Chart, {
|
|
90
|
+
props: { spec: barSpec, darkMode: 'off' },
|
|
91
|
+
});
|
|
92
|
+
await waitFor(() => {
|
|
93
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await rerender({ spec: barSpec, darkMode: 'force' });
|
|
97
|
+
await waitFor(() => {
|
|
98
|
+
expect(container.querySelector('svg')).not.toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useTableState composable.
|
|
3
|
+
*
|
|
4
|
+
* Since useTableState uses $state runes, it requires running within a Svelte
|
|
5
|
+
* reactive context. We test by verifying the returned object's behavior
|
|
6
|
+
* directly since $state creates reactive getters/setters that work
|
|
7
|
+
* synchronously outside of $effect tracking.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import { useTableState } from '../composables/useTableState.svelte.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Tests
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
describe('useTableState', () => {
|
|
18
|
+
// -----------------------------------------------------------------------
|
|
19
|
+
// Default initial state
|
|
20
|
+
// -----------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
it('initializes with default values when no options provided', () => {
|
|
23
|
+
const state = useTableState();
|
|
24
|
+
|
|
25
|
+
expect(state.sort).toBeNull();
|
|
26
|
+
expect(state.search).toBe('');
|
|
27
|
+
expect(state.page).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// -----------------------------------------------------------------------
|
|
31
|
+
// Custom initial state
|
|
32
|
+
// -----------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
it('initializes with provided sort state', () => {
|
|
35
|
+
const state = useTableState({
|
|
36
|
+
sort: { column: 'name', direction: 'asc' },
|
|
37
|
+
});
|
|
38
|
+
expect(state.sort).toEqual({ column: 'name', direction: 'asc' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('initializes with provided search string', () => {
|
|
42
|
+
const state = useTableState({ search: 'hello' });
|
|
43
|
+
expect(state.search).toBe('hello');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('initializes with provided page number', () => {
|
|
47
|
+
const state = useTableState({ page: 3 });
|
|
48
|
+
expect(state.page).toBe(3);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// -----------------------------------------------------------------------
|
|
52
|
+
// State setters
|
|
53
|
+
// -----------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
it('setSort updates the sort state', () => {
|
|
56
|
+
const state = useTableState();
|
|
57
|
+
|
|
58
|
+
state.setSort({ column: 'age', direction: 'desc' });
|
|
59
|
+
expect(state.sort).toEqual({ column: 'age', direction: 'desc' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('setSort can clear sort by passing null', () => {
|
|
63
|
+
const state = useTableState({
|
|
64
|
+
sort: { column: 'name', direction: 'asc' },
|
|
65
|
+
});
|
|
66
|
+
expect(state.sort).toEqual({ column: 'name', direction: 'asc' });
|
|
67
|
+
|
|
68
|
+
state.setSort(null);
|
|
69
|
+
expect(state.sort).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('setSearch updates the search query', () => {
|
|
73
|
+
const state = useTableState();
|
|
74
|
+
|
|
75
|
+
state.setSearch('filter text');
|
|
76
|
+
expect(state.search).toBe('filter text');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('setPage updates the current page', () => {
|
|
80
|
+
const state = useTableState();
|
|
81
|
+
|
|
82
|
+
state.setPage(5);
|
|
83
|
+
expect(state.page).toBe(5);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// -----------------------------------------------------------------------
|
|
87
|
+
// resetState
|
|
88
|
+
// -----------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
it('resetState restores default initial values', () => {
|
|
91
|
+
const state = useTableState();
|
|
92
|
+
|
|
93
|
+
// Change all values
|
|
94
|
+
state.setSort({ column: 'name', direction: 'asc' });
|
|
95
|
+
state.setSearch('filter text');
|
|
96
|
+
state.setPage(5);
|
|
97
|
+
|
|
98
|
+
expect(state.sort).toEqual({ column: 'name', direction: 'asc' });
|
|
99
|
+
expect(state.search).toBe('filter text');
|
|
100
|
+
expect(state.page).toBe(5);
|
|
101
|
+
|
|
102
|
+
// Reset
|
|
103
|
+
state.resetState();
|
|
104
|
+
|
|
105
|
+
expect(state.sort).toBeNull();
|
|
106
|
+
expect(state.search).toBe('');
|
|
107
|
+
expect(state.page).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('resetState restores custom initial values', () => {
|
|
111
|
+
const initialState = {
|
|
112
|
+
sort: { column: 'age', direction: 'desc' as const },
|
|
113
|
+
search: 'initial',
|
|
114
|
+
page: 1,
|
|
115
|
+
};
|
|
116
|
+
const state = useTableState(initialState);
|
|
117
|
+
|
|
118
|
+
// Change all values
|
|
119
|
+
state.setSort(null);
|
|
120
|
+
state.setSearch('changed');
|
|
121
|
+
state.setPage(5);
|
|
122
|
+
|
|
123
|
+
expect(state.sort).toBeNull();
|
|
124
|
+
expect(state.search).toBe('changed');
|
|
125
|
+
expect(state.page).toBe(5);
|
|
126
|
+
|
|
127
|
+
// Reset to initial
|
|
128
|
+
state.resetState();
|
|
129
|
+
|
|
130
|
+
expect(state.sort).toEqual({ column: 'age', direction: 'desc' });
|
|
131
|
+
expect(state.search).toBe('initial');
|
|
132
|
+
expect(state.page).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useChart: composable for manual chart lifecycle control.
|
|
3
|
+
*
|
|
4
|
+
* Returns a Svelte action function for use with `use:chart` directive
|
|
5
|
+
* and exposes the chart instance and compiled layout.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script>
|
|
10
|
+
* const { action, chart, layout } = useChart(spec);
|
|
11
|
+
* </script>
|
|
12
|
+
* <div use:action></div>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Uses .svelte.ts extension so runes ($state, $effect) work outside
|
|
16
|
+
* .svelte components.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ChartLayout, ChartSpec, GraphSpec } from '@opendata-ai/openchart-core';
|
|
20
|
+
import { type ChartInstance, createChart, type MountOptions } from '@opendata-ai/openchart-vanilla';
|
|
21
|
+
import { untrack } from 'svelte';
|
|
22
|
+
|
|
23
|
+
export interface UseChartOptions {
|
|
24
|
+
/** Theme overrides. */
|
|
25
|
+
theme?: MountOptions['theme'];
|
|
26
|
+
/** Dark mode setting. */
|
|
27
|
+
darkMode?: MountOptions['darkMode'];
|
|
28
|
+
/** Data point click handler. */
|
|
29
|
+
onDataPointClick?: MountOptions['onDataPointClick'];
|
|
30
|
+
/** Enable responsive resizing. Defaults to true. */
|
|
31
|
+
responsive?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UseChartReturn {
|
|
35
|
+
/** Svelte action to attach to a container div. */
|
|
36
|
+
action: (node: HTMLElement) => { destroy: () => void };
|
|
37
|
+
/** The chart instance (null until mounted). */
|
|
38
|
+
readonly chart: ChartInstance | null;
|
|
39
|
+
/** The current compiled layout (null until mounted). */
|
|
40
|
+
readonly layout: ChartLayout | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useChart(
|
|
44
|
+
spec: () => ChartSpec | GraphSpec,
|
|
45
|
+
options?: () => UseChartOptions | undefined,
|
|
46
|
+
): UseChartReturn {
|
|
47
|
+
let chart = $state<ChartInstance | null>(null);
|
|
48
|
+
let layout = $state<ChartLayout | null>(null);
|
|
49
|
+
|
|
50
|
+
function action(node: HTMLElement) {
|
|
51
|
+
let prevSpec = '';
|
|
52
|
+
|
|
53
|
+
// Effect 1: Mount/recreate on option changes
|
|
54
|
+
$effect(() => {
|
|
55
|
+
const opts = options?.();
|
|
56
|
+
|
|
57
|
+
const mountOpts: MountOptions = {
|
|
58
|
+
theme: opts?.theme,
|
|
59
|
+
darkMode: opts?.darkMode,
|
|
60
|
+
onDataPointClick: opts?.onDataPointClick,
|
|
61
|
+
responsive: opts?.responsive ?? true,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Read spec without tracking
|
|
65
|
+
const currentSpec = untrack(() => spec());
|
|
66
|
+
|
|
67
|
+
const instance = createChart(node, currentSpec, mountOpts);
|
|
68
|
+
chart = instance;
|
|
69
|
+
layout = instance.layout;
|
|
70
|
+
prevSpec = JSON.stringify(currentSpec);
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
instance.destroy();
|
|
74
|
+
chart = null;
|
|
75
|
+
layout = null;
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Effect 2: Update on spec change
|
|
80
|
+
$effect(() => {
|
|
81
|
+
const currentSpec = spec();
|
|
82
|
+
if (!chart) return;
|
|
83
|
+
|
|
84
|
+
const specString = JSON.stringify(currentSpec);
|
|
85
|
+
if (specString !== prevSpec) {
|
|
86
|
+
prevSpec = specString;
|
|
87
|
+
chart.update(currentSpec);
|
|
88
|
+
layout = chart.layout;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
destroy() {
|
|
94
|
+
// $effect cleanup handles teardown
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
action,
|
|
101
|
+
get chart() {
|
|
102
|
+
return chart;
|
|
103
|
+
},
|
|
104
|
+
get layout() {
|
|
105
|
+
return layout;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|