@opendata-ai/openchart-react 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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Tests for useChart and useDarkMode hooks.
3
+ *
4
+ * Note: renderHook from @testing-library/react doesn't work in this project
5
+ * due to bun's React dual-instance issue (renderHook's internal TestComponent
6
+ * uses a different React copy than the hooks under test). Instead, we render
7
+ * thin wrapper components that expose hook state via the DOM, then assert
8
+ * using render + waitFor.
9
+ */
10
+
11
+ import type { ChartSpec } from '@opendata-ai/openchart-core';
12
+ import { render, waitFor } from '@testing-library/react';
13
+ import { describe, expect, it, vi } from 'vitest';
14
+ import { useChart, useDarkMode } from '../hooks';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Test data
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const barSpec: ChartSpec = {
21
+ type: 'bar',
22
+ data: [
23
+ { name: 'A', value: 10 },
24
+ { name: 'B', value: 30 },
25
+ ],
26
+ encoding: {
27
+ x: { field: 'value', type: 'quantitative' },
28
+ y: { field: 'name', type: 'nominal' },
29
+ },
30
+ };
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // useDarkMode
34
+ // ---------------------------------------------------------------------------
35
+
36
+ function DarkModeHarness({ mode }: { mode?: 'auto' | 'force' | 'off' }) {
37
+ const isDark = useDarkMode(mode);
38
+ return <div data-testid="dark-mode">{String(isDark)}</div>;
39
+ }
40
+
41
+ describe('useDarkMode', () => {
42
+ it('returns false when no mode is provided', async () => {
43
+ const { container } = render(<DarkModeHarness />);
44
+ await waitFor(() => {
45
+ expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('false');
46
+ });
47
+ });
48
+
49
+ it('returns false for "off" mode', async () => {
50
+ const { container } = render(<DarkModeHarness mode="off" />);
51
+ await waitFor(() => {
52
+ expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('false');
53
+ });
54
+ });
55
+
56
+ it('returns true for "force" mode', async () => {
57
+ const { container } = render(<DarkModeHarness mode="force" />);
58
+ await waitFor(() => {
59
+ expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('true');
60
+ });
61
+ });
62
+
63
+ it('reflects system preference for "auto" mode when dark', async () => {
64
+ const spy = vi.spyOn(window, 'matchMedia').mockImplementation(
65
+ (query: string) =>
66
+ ({
67
+ matches: query === '(prefers-color-scheme: dark)',
68
+ media: query,
69
+ addEventListener: vi.fn(),
70
+ removeEventListener: vi.fn(),
71
+ }) as unknown as MediaQueryList,
72
+ );
73
+
74
+ const { container } = render(<DarkModeHarness mode="auto" />);
75
+ await waitFor(() => {
76
+ expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('true');
77
+ });
78
+
79
+ spy.mockRestore();
80
+ });
81
+
82
+ it('reflects system preference for "auto" mode when light', async () => {
83
+ const spy = vi.spyOn(window, 'matchMedia').mockImplementation(
84
+ (query: string) =>
85
+ ({
86
+ matches: false,
87
+ media: query,
88
+ addEventListener: vi.fn(),
89
+ removeEventListener: vi.fn(),
90
+ }) as unknown as MediaQueryList,
91
+ );
92
+
93
+ const { container } = render(<DarkModeHarness mode="auto" />);
94
+ await waitFor(() => {
95
+ expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('false');
96
+ });
97
+
98
+ spy.mockRestore();
99
+ });
100
+
101
+ it('switches from "off" to "force" when mode prop changes', async () => {
102
+ const { container, rerender } = render(<DarkModeHarness mode="off" />);
103
+ await waitFor(() => {
104
+ expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('false');
105
+ });
106
+
107
+ rerender(<DarkModeHarness mode="force" />);
108
+ await waitFor(() => {
109
+ expect(container.querySelector('[data-testid="dark-mode"]')?.textContent).toBe('true');
110
+ });
111
+ });
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // useChart
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function ChartHookHarness({ spec }: { spec: ChartSpec }) {
119
+ const { ref, chart, layout } = useChart(spec);
120
+ return (
121
+ <div>
122
+ <div ref={ref} data-testid="chart-container" />
123
+ <span data-testid="has-chart">{String(chart !== null)}</span>
124
+ <span data-testid="has-layout">{String(layout !== null)}</span>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ describe('useChart', () => {
130
+ it('mounts a chart instance into the ref container', async () => {
131
+ const { container } = render(<ChartHookHarness spec={barSpec} />);
132
+
133
+ await waitFor(() => {
134
+ const chartContainer = container.querySelector('[data-testid="chart-container"]');
135
+ const svg = chartContainer?.querySelector('svg');
136
+ expect(svg).not.toBeNull();
137
+ });
138
+ });
139
+
140
+ it('produces a non-null layout after mounting', async () => {
141
+ const { container } = render(<ChartHookHarness spec={barSpec} />);
142
+
143
+ await waitFor(() => {
144
+ expect(container.querySelector('[data-testid="has-layout"]')?.textContent).toBe('true');
145
+ });
146
+ });
147
+
148
+ it('cleans up the chart on unmount', async () => {
149
+ const { container, unmount } = render(<ChartHookHarness spec={barSpec} />);
150
+
151
+ await waitFor(() => {
152
+ const svg = container.querySelector('svg');
153
+ expect(svg).not.toBeNull();
154
+ });
155
+
156
+ unmount();
157
+
158
+ expect(container.querySelector('svg')).toBeNull();
159
+ });
160
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Tests that spec changes go through the update() path rather than
3
+ * destroying and recreating the chart/table instance.
4
+ */
5
+
6
+ import type { ChartSpec, TableSpec } from '@opendata-ai/openchart-core';
7
+ import { cleanup, render, waitFor } from '@testing-library/react';
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+ import { Chart } from '../Chart';
10
+ import { DataTable } from '../DataTable';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Mock the vanilla adapter to track create/update/destroy calls
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const mockChartInstance = {
17
+ update: vi.fn(),
18
+ resize: vi.fn(),
19
+ destroy: vi.fn(),
20
+ export: vi.fn(),
21
+ layout: {} as unknown,
22
+ };
23
+
24
+ const mockTableInstance = {
25
+ update: vi.fn(),
26
+ setState: vi.fn(),
27
+ destroy: vi.fn(),
28
+ layout: {} as unknown,
29
+ };
30
+
31
+ vi.mock('@opendata-ai/openchart-vanilla', () => ({
32
+ createChart: vi.fn(() => ({ ...mockChartInstance })),
33
+ createTable: vi.fn(() => ({ ...mockTableInstance })),
34
+ createGraph: vi.fn(),
35
+ }));
36
+
37
+ // Import after mock setup
38
+ const { createChart, createTable } = await import('@opendata-ai/openchart-vanilla');
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Test data
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const lineSpec: ChartSpec = {
45
+ type: 'line',
46
+ data: [
47
+ { x: '2020', y: 10 },
48
+ { x: '2021', y: 20 },
49
+ ],
50
+ encoding: {
51
+ x: { field: 'x', type: 'temporal' },
52
+ y: { field: 'y', type: 'quantitative' },
53
+ },
54
+ };
55
+
56
+ const barSpec: ChartSpec = {
57
+ type: 'bar',
58
+ data: [
59
+ { name: 'A', value: 10 },
60
+ { name: 'B', value: 20 },
61
+ ],
62
+ encoding: {
63
+ x: { field: 'value', type: 'quantitative' },
64
+ y: { field: 'name', type: 'nominal' },
65
+ },
66
+ };
67
+
68
+ const tableSpec: TableSpec = {
69
+ type: 'table',
70
+ data: [{ a: 1 }],
71
+ columns: [{ key: 'a', label: 'A' }],
72
+ };
73
+
74
+ const updatedTableSpec: TableSpec = {
75
+ type: 'table',
76
+ data: [{ a: 1 }, { a: 2 }],
77
+ columns: [{ key: 'a', label: 'A' }],
78
+ };
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Tests
82
+ // ---------------------------------------------------------------------------
83
+
84
+ afterEach(() => {
85
+ cleanup();
86
+ vi.clearAllMocks();
87
+ });
88
+
89
+ describe('Chart update-vs-remount', () => {
90
+ it('calls createChart once on initial mount', async () => {
91
+ render(<Chart spec={lineSpec} />);
92
+ await waitFor(() => {
93
+ expect(createChart).toHaveBeenCalledTimes(1);
94
+ });
95
+ });
96
+
97
+ it('calls update() not createChart() on spec change', async () => {
98
+ const { rerender } = render(<Chart spec={lineSpec} />);
99
+ await waitFor(() => {
100
+ expect(createChart).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ rerender(<Chart spec={barSpec} />);
104
+ await waitFor(() => {
105
+ // createChart should NOT be called again
106
+ expect(createChart).toHaveBeenCalledTimes(1);
107
+ // update should be called with the new spec
108
+ expect(mockChartInstance.update).toHaveBeenCalledWith(barSpec);
109
+ });
110
+ });
111
+ });
112
+
113
+ describe('DataTable update-vs-remount', () => {
114
+ it('calls createTable once on initial mount', async () => {
115
+ render(<DataTable spec={tableSpec} />);
116
+ await waitFor(() => {
117
+ expect(createTable).toHaveBeenCalledTimes(1);
118
+ });
119
+ });
120
+
121
+ it('calls update() not createTable() on spec change', async () => {
122
+ const { rerender } = render(<DataTable spec={tableSpec} />);
123
+ await waitFor(() => {
124
+ expect(createTable).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ rerender(<DataTable spec={updatedTableSpec} />);
128
+ await waitFor(() => {
129
+ expect(createTable).toHaveBeenCalledTimes(1);
130
+ expect(mockTableInstance.update).toHaveBeenCalledWith(updatedTableSpec);
131
+ });
132
+ });
133
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Tests for useTableState hook.
3
+ *
4
+ * Uses thin wrapper components that expose hook state via the DOM,
5
+ * since renderHook is broken by bun's React dual-instance issue.
6
+ */
7
+
8
+ import { fireEvent, render, waitFor } from '@testing-library/react';
9
+ import { describe, expect, it } from 'vitest';
10
+ import { type UseTableStateOptions, useTableState } from '../hooks/useTableState';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Test harness: renders hook state to the DOM
14
+ // ---------------------------------------------------------------------------
15
+
16
+ interface HarnessProps {
17
+ initialState?: UseTableStateOptions;
18
+ }
19
+
20
+ function TableStateHarness({ initialState }: HarnessProps) {
21
+ const { sort, search, page, setSort, setSearch, setPage, resetState } =
22
+ useTableState(initialState);
23
+
24
+ return (
25
+ <div>
26
+ <span data-testid="sort">{sort ? `${sort.column}:${sort.direction}` : 'null'}</span>
27
+ <span data-testid="search">{search}</span>
28
+ <span data-testid="page">{String(page)}</span>
29
+ <button
30
+ type="button"
31
+ data-testid="set-sort-name-asc"
32
+ onClick={() => setSort({ column: 'name', direction: 'asc' })}
33
+ >
34
+ sort name asc
35
+ </button>
36
+ <button
37
+ type="button"
38
+ data-testid="set-sort-age-desc"
39
+ onClick={() => setSort({ column: 'age', direction: 'desc' })}
40
+ >
41
+ sort age desc
42
+ </button>
43
+ <button type="button" data-testid="clear-sort" onClick={() => setSort(null)}>
44
+ clear sort
45
+ </button>
46
+ <button type="button" data-testid="set-search" onClick={() => setSearch('filter text')}>
47
+ set search
48
+ </button>
49
+ <button type="button" data-testid="set-page-5" onClick={() => setPage(5)}>
50
+ page 5
51
+ </button>
52
+ <button type="button" data-testid="reset" onClick={() => resetState()}>
53
+ reset
54
+ </button>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Helper
61
+ // ---------------------------------------------------------------------------
62
+
63
+ async function renderHarness(initialState?: UseTableStateOptions) {
64
+ const result = render(<TableStateHarness initialState={initialState} />);
65
+ // Wait for initial render to complete
66
+ await waitFor(() => {
67
+ expect(result.container.querySelector('[data-testid="page"]')).not.toBeNull();
68
+ });
69
+ return result;
70
+ }
71
+
72
+ function getState(container: HTMLElement) {
73
+ return {
74
+ sort: container.querySelector('[data-testid="sort"]')?.textContent ?? '',
75
+ search: container.querySelector('[data-testid="search"]')?.textContent ?? '',
76
+ page: container.querySelector('[data-testid="page"]')?.textContent ?? '',
77
+ };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Tests
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe('useTableState', () => {
85
+ // -------------------------------------------------------------------------
86
+ // Default initial state
87
+ // -------------------------------------------------------------------------
88
+
89
+ it('initializes with default values when no options provided', async () => {
90
+ const { container } = await renderHarness();
91
+ const state = getState(container);
92
+
93
+ expect(state.sort).toBe('null');
94
+ expect(state.search).toBe('');
95
+ expect(state.page).toBe('0');
96
+ });
97
+
98
+ // -------------------------------------------------------------------------
99
+ // Custom initial state
100
+ // -------------------------------------------------------------------------
101
+
102
+ it('initializes with provided sort state', async () => {
103
+ const { container } = await renderHarness({
104
+ sort: { column: 'name', direction: 'asc' },
105
+ });
106
+ expect(getState(container).sort).toBe('name:asc');
107
+ });
108
+
109
+ it('initializes with provided search string', async () => {
110
+ const { container } = await renderHarness({ search: 'hello' });
111
+ expect(getState(container).search).toBe('hello');
112
+ });
113
+
114
+ it('initializes with provided page number', async () => {
115
+ const { container } = await renderHarness({ page: 3 });
116
+ expect(getState(container).page).toBe('3');
117
+ });
118
+
119
+ // -------------------------------------------------------------------------
120
+ // State setters
121
+ // -------------------------------------------------------------------------
122
+
123
+ it('setSort updates the sort state', async () => {
124
+ const { container } = await renderHarness();
125
+
126
+ fireEvent.click(container.querySelector('[data-testid="set-sort-age-desc"]')!);
127
+ await waitFor(() => {
128
+ expect(getState(container).sort).toBe('age:desc');
129
+ });
130
+ });
131
+
132
+ it('setSort can clear sort by passing null', async () => {
133
+ const { container } = await renderHarness({
134
+ sort: { column: 'name', direction: 'asc' },
135
+ });
136
+ expect(getState(container).sort).toBe('name:asc');
137
+
138
+ fireEvent.click(container.querySelector('[data-testid="clear-sort"]')!);
139
+ await waitFor(() => {
140
+ expect(getState(container).sort).toBe('null');
141
+ });
142
+ });
143
+
144
+ it('setSearch updates the search query', async () => {
145
+ const { container } = await renderHarness();
146
+
147
+ fireEvent.click(container.querySelector('[data-testid="set-search"]')!);
148
+ await waitFor(() => {
149
+ expect(getState(container).search).toBe('filter text');
150
+ });
151
+ });
152
+
153
+ it('setPage updates the current page', async () => {
154
+ const { container } = await renderHarness();
155
+
156
+ fireEvent.click(container.querySelector('[data-testid="set-page-5"]')!);
157
+ await waitFor(() => {
158
+ expect(getState(container).page).toBe('5');
159
+ });
160
+ });
161
+
162
+ // -------------------------------------------------------------------------
163
+ // resetState
164
+ // -------------------------------------------------------------------------
165
+
166
+ it('resetState restores default initial values', async () => {
167
+ const { container } = await renderHarness();
168
+
169
+ // Change all values
170
+ fireEvent.click(container.querySelector('[data-testid="set-sort-name-asc"]')!);
171
+ fireEvent.click(container.querySelector('[data-testid="set-search"]')!);
172
+ fireEvent.click(container.querySelector('[data-testid="set-page-5"]')!);
173
+
174
+ await waitFor(() => {
175
+ expect(getState(container).sort).toBe('name:asc');
176
+ expect(getState(container).search).toBe('filter text');
177
+ expect(getState(container).page).toBe('5');
178
+ });
179
+
180
+ // Reset
181
+ fireEvent.click(container.querySelector('[data-testid="reset"]')!);
182
+
183
+ await waitFor(() => {
184
+ expect(getState(container).sort).toBe('null');
185
+ expect(getState(container).search).toBe('');
186
+ expect(getState(container).page).toBe('0');
187
+ });
188
+ });
189
+
190
+ it('resetState restores custom initial values', async () => {
191
+ const initialState = {
192
+ sort: { column: 'age', direction: 'desc' as const },
193
+ search: 'initial',
194
+ page: 1,
195
+ };
196
+ const { container } = await renderHarness(initialState);
197
+
198
+ // Change all values
199
+ fireEvent.click(container.querySelector('[data-testid="clear-sort"]')!);
200
+ fireEvent.click(container.querySelector('[data-testid="set-search"]')!);
201
+ fireEvent.click(container.querySelector('[data-testid="set-page-5"]')!);
202
+
203
+ await waitFor(() => {
204
+ expect(getState(container).sort).toBe('null');
205
+ expect(getState(container).search).toBe('filter text');
206
+ expect(getState(container).page).toBe('5');
207
+ });
208
+
209
+ // Reset to initial
210
+ fireEvent.click(container.querySelector('[data-testid="reset"]')!);
211
+
212
+ await waitFor(() => {
213
+ expect(getState(container).sort).toBe('age:desc');
214
+ expect(getState(container).search).toBe('initial');
215
+ expect(getState(container).page).toBe('1');
216
+ });
217
+ });
218
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * useGraph: hook for imperative graph control.
3
+ *
4
+ * Provides a ref to pass to <Graph /> and exposes graph methods
5
+ * (search, zoom, select) for programmatic control of the graph instance.
6
+ */
7
+
8
+ import type { GraphInstance } from '@opendata-ai/openchart-vanilla';
9
+ import { useCallback, useRef } from 'react';
10
+
11
+ export interface UseGraphReturn {
12
+ /** Ref to pass to <Graph ref={ref} />. */
13
+ ref: React.RefObject<GraphHandle | null>;
14
+ /** Search for nodes matching a query string. */
15
+ search: (query: string) => void;
16
+ /** Clear the current search. */
17
+ clearSearch: () => void;
18
+ /** Zoom to fit all nodes in view. */
19
+ zoomToFit: () => void;
20
+ /** Zoom and center on a specific node. */
21
+ zoomToNode: (nodeId: string) => void;
22
+ /** Select a node by id. */
23
+ selectNode: (nodeId: string) => void;
24
+ /** Get the currently selected node ids. */
25
+ getSelectedNodes: () => string[];
26
+ }
27
+
28
+ /** Handle exposed by Graph component via forwardRef. */
29
+ export interface GraphHandle {
30
+ search: (query: string) => void;
31
+ clearSearch: () => void;
32
+ zoomToFit: () => void;
33
+ zoomToNode: (nodeId: string) => void;
34
+ selectNode: (nodeId: string) => void;
35
+ getSelectedNodes: () => string[];
36
+ /** The underlying GraphInstance from the vanilla adapter. */
37
+ instance: GraphInstance | null;
38
+ }
39
+
40
+ /**
41
+ * Hook for imperative graph control.
42
+ *
43
+ * Usage:
44
+ * ```tsx
45
+ * const { ref, search, zoomToFit } = useGraph();
46
+ * return <Graph ref={ref} spec={spec} />;
47
+ * ```
48
+ */
49
+ export function useGraph(): UseGraphReturn {
50
+ const ref = useRef<GraphHandle | null>(null);
51
+
52
+ const search = useCallback((query: string) => {
53
+ ref.current?.search(query);
54
+ }, []);
55
+
56
+ const clearSearch = useCallback(() => {
57
+ ref.current?.clearSearch();
58
+ }, []);
59
+
60
+ const zoomToFit = useCallback(() => {
61
+ ref.current?.zoomToFit();
62
+ }, []);
63
+
64
+ const zoomToNode = useCallback((nodeId: string) => {
65
+ ref.current?.zoomToNode(nodeId);
66
+ }, []);
67
+
68
+ const selectNode = useCallback((nodeId: string) => {
69
+ ref.current?.selectNode(nodeId);
70
+ }, []);
71
+
72
+ const getSelectedNodes = useCallback((): string[] => {
73
+ return ref.current?.getSelectedNodes() ?? [];
74
+ }, []);
75
+
76
+ return {
77
+ ref,
78
+ search,
79
+ clearSearch,
80
+ zoomToFit,
81
+ zoomToNode,
82
+ selectNode,
83
+ getSelectedNodes,
84
+ };
85
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * useTable: hook for manual table lifecycle control.
3
+ *
4
+ * Attaches to a container ref, mounts a vanilla table instance,
5
+ * and exposes the instance and current state.
6
+ */
7
+
8
+ import type { TableSpec } from '@opendata-ai/openchart-core';
9
+ import {
10
+ createTable,
11
+ type TableInstance,
12
+ type TableMountOptions,
13
+ type TableState,
14
+ } from '@opendata-ai/openchart-vanilla';
15
+ import { useCallback, useEffect, useRef, useState } from 'react';
16
+
17
+ export interface UseTableReturn {
18
+ /** Ref to attach to the container div. */
19
+ ref: React.RefObject<HTMLDivElement | null>;
20
+ /** The table instance (null until mounted). */
21
+ table: TableInstance | null;
22
+ /** The current table state (sort, search, page). */
23
+ state: TableState;
24
+ }
25
+
26
+ /**
27
+ * Hook for manual table lifecycle control.
28
+ *
29
+ * Attach the returned ref to a container div. The table mounts
30
+ * automatically and updates when the spec changes.
31
+ *
32
+ * @param spec - The table spec.
33
+ * @param options - Mount options.
34
+ * @returns { ref, table, state }
35
+ */
36
+ export function useTable(spec: TableSpec, options?: TableMountOptions): UseTableReturn {
37
+ const ref = useRef<HTMLDivElement | null>(null);
38
+ const tableRef = useRef<TableInstance | null>(null);
39
+ const [state, setState] = useState<TableState>({
40
+ sort: null,
41
+ search: '',
42
+ page: 0,
43
+ });
44
+
45
+ const originalOnStateChange = options?.onStateChange;
46
+
47
+ const handleStateChange = useCallback(
48
+ (newState: TableState) => {
49
+ setState(newState);
50
+ originalOnStateChange?.(newState);
51
+ },
52
+ [originalOnStateChange],
53
+ );
54
+
55
+ // Mount / unmount
56
+ useEffect(() => {
57
+ const container = ref.current;
58
+ if (!container) return;
59
+
60
+ const mountOpts: TableMountOptions = {
61
+ ...options,
62
+ onStateChange: handleStateChange,
63
+ };
64
+
65
+ const table = createTable(container, spec, mountOpts);
66
+ tableRef.current = table;
67
+ setState(table.getState());
68
+
69
+ return () => {
70
+ table.destroy();
71
+ tableRef.current = null;
72
+ };
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [
75
+ options?.theme,
76
+ options?.darkMode,
77
+ options?.onRowClick,
78
+ options?.responsive,
79
+ handleStateChange,
80
+ options,
81
+ spec,
82
+ ]);
83
+
84
+ // Update on spec change
85
+ useEffect(() => {
86
+ const table = tableRef.current;
87
+ if (!table) return;
88
+
89
+ table.update(spec);
90
+ setState(table.getState());
91
+ }, [spec]);
92
+
93
+ return {
94
+ ref,
95
+ table: tableRef.current,
96
+ state,
97
+ };
98
+ }