diff --git a/test/typedCharts.test.tsx b/test/typedCharts.test.tsx
new file mode 100644
index 000000000..689a9a990
--- /dev/null
+++ b/test/typedCharts.test.tsx
@@ -0,0 +1,420 @@
+import { vi, describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+import 'chart.js/auto';
+import { Chart as ChartJS } from 'chart.js';
+import {
+ Line,
+ Bar,
+ Radar,
+ Doughnut,
+ PolarArea,
+ Bubble,
+ Pie,
+ Scatter,
+} from '../src/index.js';
+
+describe('Typed Chart Components', () => {
+ const basicData = {
+ labels: ['Red', 'Blue', 'Yellow'],
+ datasets: [
+ {
+ label: 'Dataset 1',
+ data: [12, 19, 3],
+ backgroundColor: [
+ 'rgba(255, 99, 132, 0.2)',
+ 'rgba(54, 162, 235, 0.2)',
+ 'rgba(255, 206, 86, 0.2)',
+ ],
+ },
+ ],
+ };
+
+ const options = {
+ responsive: false,
+ };
+
+ let chart: ChartJS | null = null;
+
+ const ref = (el: ChartJS | null) => {
+ chart = el;
+ };
+
+ afterEach(() => {
+ if (chart) {
+ chart.destroy();
+ chart = null;
+ }
+ cleanup();
+ });
+
+ describe('Line Chart', () => {
+ it('should render Line chart', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart instanceof ChartJS).toBe(true);
+ expect(chart?.config.type).toBe('line');
+ });
+
+ it('should pass data to Line chart', () => {
+ render();
+
+ expect(chart?.config.data.labels).toEqual(basicData.labels);
+ expect(chart?.config.data.datasets).toHaveLength(1);
+ expect(chart?.config.data.datasets[0].label).toBe('Dataset 1');
+ });
+
+ it('should pass options to Line chart', () => {
+ render();
+
+ expect(chart?.options.responsive).toBe(false);
+ });
+
+ it('should update Line chart on data change', () => {
+ const newData = {
+ labels: ['Green', 'Purple'],
+ datasets: [
+ {
+ label: 'Updated Dataset',
+ data: [5, 10],
+ },
+ ],
+ };
+
+ const { rerender } = render(
+
+ );
+
+ const updateSpy = vi.spyOn(chart!, 'update');
+
+ rerender();
+
+ expect(updateSpy).toHaveBeenCalled();
+ expect(chart?.config.data.labels).toEqual(newData.labels);
+ });
+ });
+
+ describe('Bar Chart', () => {
+ it('should render Bar chart', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart instanceof ChartJS).toBe(true);
+ expect(chart?.config.type).toBe('bar');
+ });
+
+ it('should pass data to Bar chart', () => {
+ render();
+
+ expect(chart?.config.data.labels).toEqual(basicData.labels);
+ expect(chart?.config.data.datasets).toHaveLength(1);
+ });
+
+ it('should handle horizontal bar options', () => {
+ const horizontalOptions = {
+ indexAxis: 'y' as const,
+ responsive: false,
+ };
+
+ render();
+
+ expect(chart?.options.indexAxis).toBe('y');
+ });
+ });
+
+ describe('Radar Chart', () => {
+ it('should render Radar chart', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart instanceof ChartJS).toBe(true);
+ expect(chart?.config.type).toBe('radar');
+ });
+
+ it('should pass data to Radar chart', () => {
+ render();
+
+ expect(chart?.config.data.labels).toEqual(basicData.labels);
+ expect(chart?.config.data.datasets).toHaveLength(1);
+ });
+ });
+
+ describe('Doughnut Chart', () => {
+ it('should render Doughnut chart', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart instanceof ChartJS).toBe(true);
+ expect(chart?.config.type).toBe('doughnut');
+ });
+
+ it('should pass data to Doughnut chart', () => {
+ render();
+
+ expect(chart?.config.data.labels).toEqual(basicData.labels);
+ expect(chart?.config.data.datasets).toHaveLength(1);
+ });
+
+ it('should handle doughnut-specific options', () => {
+ const doughnutOptions = {
+ cutout: '70%',
+ responsive: false,
+ };
+
+ render(
+
+ );
+
+ expect(chart?.options.cutout).toBe('70%');
+ });
+ });
+
+ describe('PolarArea Chart', () => {
+ it('should render PolarArea chart', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart instanceof ChartJS).toBe(true);
+ expect(chart?.config.type).toBe('polarArea');
+ });
+
+ it('should pass data to PolarArea chart', () => {
+ render();
+
+ expect(chart?.config.data.labels).toEqual(basicData.labels);
+ expect(chart?.config.data.datasets).toHaveLength(1);
+ });
+ });
+
+ describe('Bubble Chart', () => {
+ const bubbleData = {
+ datasets: [
+ {
+ label: 'Bubble Dataset',
+ data: [
+ { x: 20, y: 30, r: 15 },
+ { x: 40, y: 10, r: 10 },
+ ],
+ backgroundColor: 'rgba(255, 99, 132, 0.2)',
+ },
+ ],
+ };
+
+ it('should render Bubble chart', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart instanceof ChartJS).toBe(true);
+ expect(chart?.config.type).toBe('bubble');
+ });
+
+ it('should pass data to Bubble chart', () => {
+ render();
+
+ expect(chart?.config.data.datasets).toHaveLength(1);
+ expect(chart?.config.data.datasets[0].data).toHaveLength(2);
+ });
+ });
+
+ describe('Pie Chart', () => {
+ it('should render Pie chart', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart instanceof ChartJS).toBe(true);
+ expect(chart?.config.type).toBe('pie');
+ });
+
+ it('should pass data to Pie chart', () => {
+ render();
+
+ expect(chart?.config.data.labels).toEqual(basicData.labels);
+ expect(chart?.config.data.datasets).toHaveLength(1);
+ });
+ });
+
+ describe('Scatter Chart', () => {
+ const scatterData = {
+ datasets: [
+ {
+ label: 'Scatter Dataset',
+ data: [
+ { x: -10, y: 0 },
+ { x: 0, y: 10 },
+ { x: 10, y: 5 },
+ ],
+ backgroundColor: 'rgba(255, 99, 132, 0.2)',
+ },
+ ],
+ };
+
+ it('should render Scatter chart', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart instanceof ChartJS).toBe(true);
+ expect(chart?.config.type).toBe('scatter');
+ });
+
+ it('should pass data to Scatter chart', () => {
+ render();
+
+ expect(chart?.config.data.datasets).toHaveLength(1);
+ expect(chart?.config.data.datasets[0].data).toHaveLength(3);
+ });
+ });
+
+ describe('Common functionality across all typed charts', () => {
+ const charts = [
+ { name: 'Line', Component: Line, type: 'line' },
+ { name: 'Bar', Component: Bar, type: 'bar' },
+ { name: 'Radar', Component: Radar, type: 'radar' },
+ { name: 'Doughnut', Component: Doughnut, type: 'doughnut' },
+ { name: 'PolarArea', Component: PolarArea, type: 'polarArea' },
+ { name: 'Pie', Component: Pie, type: 'pie' },
+ ];
+
+ charts.forEach(({ name, Component, type }) => {
+ describe(`${name} common features`, () => {
+ it('should forward ref correctly', () => {
+ render();
+
+ expect(chart).toBeTruthy();
+ expect(chart?.config.type).toBe(type);
+ });
+
+ it('should destroy chart on unmount', () => {
+ const { unmount } = render(
+
+ );
+
+ expect(chart).toBeTruthy();
+ const destroySpy = vi.spyOn(chart!, 'destroy');
+
+ unmount();
+
+ expect(destroySpy).toHaveBeenCalled();
+ });
+
+ it('should accept className prop', () => {
+ render(
+
+ );
+
+ expect(chart?.canvas).toHaveClass('custom-chart');
+ });
+
+ it('should accept plugins prop', () => {
+ const customPlugin = {
+ id: 'customPlugin',
+ beforeDraw: vi.fn(),
+ };
+
+ render(
+
+ );
+
+ expect(chart?.config.plugins).toContain(customPlugin);
+ });
+
+ it('should handle redraw prop', () => {
+ const newData = {
+ labels: ['A', 'B'],
+ datasets: [{ label: 'New', data: [1, 2] }],
+ };
+
+ const { rerender } = render(
+
+ );
+
+ const originalChart = chart;
+ const destroySpy = vi.spyOn(originalChart!, 'destroy');
+
+ rerender(
+
+ );
+
+ expect(destroySpy).toHaveBeenCalled();
+ });
+
+ it('should pass through aria-label', () => {
+ const ariaLabel = `${name} Chart`;
+
+ render(
+
+ );
+
+ expect(chart?.canvas.getAttribute('aria-label')).toBe(ariaLabel);
+ });
+
+ it('should handle height and width props', () => {
+ render(
+
+ );
+
+ expect(chart?.canvas.height).toBe(400);
+ expect(chart?.canvas.width).toBe(600);
+ });
+ });
+ });
+ });
+
+ describe('Type safety and registration', () => {
+ it('should not require type prop for typed components', () => {
+ // This test verifies that typed components work without explicit type prop
+ render();
+ expect(chart?.config.type).toBe('line');
+
+ if (chart) chart.destroy();
+ chart = null;
+
+ render();
+ expect(chart?.config.type).toBe('bar');
+ });
+
+ it('should have controllers registered', () => {
+ // Verify that creating each chart type registers its controller
+ const chartTypes = [
+ { Component: Line, type: 'line' },
+ { Component: Bar, type: 'bar' },
+ { Component: Radar, type: 'radar' },
+ { Component: Doughnut, type: 'doughnut' },
+ { Component: PolarArea, type: 'polarArea' },
+ { Component: Bubble, type: 'bubble' },
+ { Component: Pie, type: 'pie' },
+ { Component: Scatter, type: 'scatter' },
+ ];
+
+ chartTypes.forEach(({ Component, type }) => {
+ render();
+ expect(chart?.config.type).toBe(type);
+
+ if (chart) {
+ chart.destroy();
+ chart = null;
+ }
+ });
+ });
+ });
+});
diff --git a/test/utils.test.tsx b/test/utils.test.tsx
new file mode 100644
index 000000000..29d976a4e
--- /dev/null
+++ b/test/utils.test.tsx
@@ -0,0 +1,497 @@
+import { vi, describe, it, expect, beforeEach } from 'vitest';
+import { fireEvent, render } from '@testing-library/react';
+import 'chart.js/auto';
+import { Chart as ChartJS } from 'chart.js';
+import { Chart } from '../src/index.js';
+import {
+ reforwardRef,
+ setOptions,
+ setLabels,
+ setDatasets,
+ cloneData,
+ getDatasetAtEvent,
+ getElementAtEvent,
+ getElementsAtEvent,
+} from '../src/utils.js';
+import type { ChartData, ChartOptions } from 'chart.js';
+
+describe('Utils', () => {
+ describe('reforwardRef', () => {
+ it('should call function ref with value', () => {
+ const ref = vi.fn();
+ const value = { test: 'value' };
+
+ reforwardRef(ref, value);
+
+ expect(ref).toHaveBeenCalledWith(value);
+ expect(ref).toHaveBeenCalledTimes(1);
+ });
+
+ it('should set current property on object ref', () => {
+ const ref = { current: null };
+ const value = { test: 'value' };
+
+ reforwardRef(ref, value);
+
+ expect(ref.current).toBe(value);
+ });
+
+ it('should handle null ref gracefully', () => {
+ expect(() => reforwardRef(null, { test: 'value' })).not.toThrow();
+ });
+
+ it('should handle undefined ref gracefully', () => {
+ expect(() => reforwardRef(undefined, { test: 'value' })).not.toThrow();
+ });
+ });
+
+ describe('setOptions', () => {
+ it('should update chart options', () => {
+ const chart = {
+ options: {
+ responsive: false,
+ maintainAspectRatio: true,
+ },
+ } as any;
+
+ const newOptions: ChartOptions = {
+ responsive: true,
+ plugins: {
+ legend: {
+ display: false,
+ },
+ },
+ };
+
+ setOptions(chart, newOptions);
+
+ expect(chart.options.responsive).toBe(true);
+ expect(chart.options.plugins?.legend?.display).toBe(false);
+ expect(chart.options.maintainAspectRatio).toBe(true);
+ });
+
+ it('should handle empty options', () => {
+ const chart = {
+ options: {},
+ } as any;
+
+ setOptions(chart, {});
+
+ expect(chart.options).toEqual({});
+ });
+
+ it('should not throw when chart has no options', () => {
+ const chart = {} as any;
+ const newOptions: ChartOptions = { responsive: true };
+
+ expect(() => setOptions(chart, newOptions)).not.toThrow();
+ });
+ });
+
+ describe('setLabels', () => {
+ it('should update labels in chart data', () => {
+ const currentData: ChartData = {
+ labels: ['old1', 'old2'],
+ datasets: [],
+ };
+
+ const newLabels = ['new1', 'new2', 'new3'];
+
+ setLabels(currentData, newLabels);
+
+ expect(currentData.labels).toEqual(newLabels);
+ });
+
+ it('should handle undefined labels', () => {
+ const currentData: ChartData = {
+ labels: ['old1', 'old2'],
+ datasets: [],
+ };
+
+ setLabels(currentData, undefined);
+
+ expect(currentData.labels).toBeUndefined();
+ });
+
+ it('should handle empty labels array', () => {
+ const currentData: ChartData = {
+ labels: ['old1', 'old2'],
+ datasets: [],
+ };
+
+ setLabels(currentData, []);
+
+ expect(currentData.labels).toEqual([]);
+ });
+ });
+
+ describe('setDatasets', () => {
+ it('should update datasets with matching labels', () => {
+ const currentData: ChartData = {
+ labels: ['A', 'B'],
+ datasets: [
+ { label: 'Dataset 1', data: [1, 2] },
+ { label: 'Dataset 2', data: [3, 4] },
+ ],
+ };
+
+ const newDatasets = [
+ { label: 'Dataset 1', data: [5, 6] },
+ { label: 'Dataset 2', data: [7, 8] },
+ ];
+
+ setDatasets(currentData, newDatasets);
+
+ expect(currentData.datasets[0].data).toEqual([5, 6]);
+ expect(currentData.datasets[1].data).toEqual([7, 8]);
+ });
+
+ it('should add new datasets when no match is found', () => {
+ const currentData: ChartData = {
+ labels: ['A', 'B'],
+ datasets: [{ label: 'Dataset 1', data: [1, 2] }],
+ };
+
+ const newDatasets = [
+ { label: 'Dataset 1', data: [5, 6] },
+ { label: 'Dataset 3', data: [9, 10] },
+ ];
+
+ setDatasets(currentData, newDatasets);
+
+ expect(currentData.datasets).toHaveLength(2);
+ expect(currentData.datasets[1].label).toBe('Dataset 3');
+ expect(currentData.datasets[1].data).toEqual([9, 10]);
+ });
+
+ it('should use custom datasetIdKey', () => {
+ const currentData: ChartData = {
+ labels: ['A', 'B'],
+ datasets: [
+ { id: 'ds1', label: 'Dataset 1', data: [1, 2] } as any,
+ { id: 'ds2', label: 'Dataset 2', data: [3, 4] } as any,
+ ],
+ };
+
+ const newDatasets = [
+ { id: 'ds1', label: 'Updated 1', data: [5, 6] } as any,
+ { id: 'ds2', label: 'Updated 2', data: [7, 8] } as any,
+ ];
+
+ setDatasets(currentData, newDatasets, 'id');
+
+ expect(currentData.datasets[0].label).toBe('Updated 1');
+ expect(currentData.datasets[1].label).toBe('Updated 2');
+ });
+
+ it('should handle datasets with no data', () => {
+ const currentData: ChartData = {
+ labels: ['A', 'B'],
+ datasets: [{ label: 'Dataset 1', data: [1, 2] }],
+ };
+
+ const newDatasets = [{ label: 'Dataset 2' } as any];
+
+ setDatasets(currentData, newDatasets);
+
+ expect(currentData.datasets).toHaveLength(1);
+ expect(currentData.datasets[0].label).toBe('Dataset 2');
+ });
+
+ it('should prevent duplicate dataset references', () => {
+ const currentData: ChartData = {
+ labels: ['A', 'B'],
+ datasets: [{ label: 'Dataset 1', data: [1, 2] }],
+ };
+
+ const newDatasets = [
+ { label: 'Dataset 1', data: [5, 6] },
+ { label: 'Dataset 1', data: [7, 8] },
+ ];
+
+ setDatasets(currentData, newDatasets);
+
+ expect(currentData.datasets).toHaveLength(2);
+ expect(currentData.datasets[0]).not.toBe(currentData.datasets[1]);
+ });
+ });
+
+ describe('cloneData', () => {
+ it('should clone chart data with labels and datasets', () => {
+ const originalData: ChartData = {
+ labels: ['A', 'B', 'C'],
+ datasets: [
+ { label: 'Dataset 1', data: [1, 2, 3] },
+ { label: 'Dataset 2', data: [4, 5, 6] },
+ ],
+ };
+
+ const clonedData = cloneData(originalData);
+
+ expect(clonedData.labels).toEqual(originalData.labels);
+ expect(clonedData.datasets).toHaveLength(2);
+ expect(clonedData.datasets[0].label).toBe('Dataset 1');
+ expect(clonedData.datasets[1].label).toBe('Dataset 2');
+ });
+
+ it('should create independent copy of data', () => {
+ const originalData: ChartData = {
+ labels: ['A', 'B'],
+ datasets: [{ label: 'Dataset 1', data: [1, 2] }],
+ };
+
+ const clonedData = cloneData(originalData);
+
+ // Modify cloned data
+ clonedData.labels = ['X', 'Y'];
+ clonedData.datasets[0].data = [99, 100];
+
+ // Original should remain unchanged
+ expect(originalData.labels).toEqual(['A', 'B']);
+ expect(originalData.datasets[0].data).toEqual([1, 2]);
+ });
+
+ it('should use custom datasetIdKey', () => {
+ const originalData: ChartData = {
+ labels: ['A', 'B'],
+ datasets: [
+ { id: 'ds1', label: 'Dataset 1', data: [1, 2] } as any,
+ { id: 'ds2', label: 'Dataset 2', data: [3, 4] } as any,
+ ],
+ };
+
+ const clonedData = cloneData(originalData, 'id');
+
+ expect(clonedData.datasets).toHaveLength(2);
+ expect(clonedData.datasets[0]).toHaveProperty('id', 'ds1');
+ expect(clonedData.datasets[1]).toHaveProperty('id', 'ds2');
+ });
+
+ it('should handle empty datasets', () => {
+ const originalData: ChartData = {
+ labels: ['A', 'B'],
+ datasets: [],
+ };
+
+ const clonedData = cloneData(originalData);
+
+ expect(clonedData.datasets).toEqual([]);
+ expect(clonedData.labels).toEqual(['A', 'B']);
+ });
+ });
+
+ describe('Event handler functions', () => {
+ let chart: ChartJS | null = null;
+
+ const data = {
+ labels: ['January', 'February', 'March'],
+ datasets: [
+ {
+ label: 'Sales',
+ data: [10, 20, 30],
+ backgroundColor: 'rgba(255, 99, 132, 0.2)',
+ },
+ {
+ label: 'Revenue',
+ data: [15, 25, 35],
+ backgroundColor: 'rgba(54, 162, 235, 0.2)',
+ },
+ ],
+ };
+
+ const options = {
+ responsive: false,
+ };
+
+ const ref = (el: ChartJS | null) => {
+ chart = el;
+ };
+
+ beforeEach(() => {
+ chart = null;
+ });
+
+ describe('getDatasetAtEvent', () => {
+ it('should return dataset elements at event location', () => {
+ const { container } = render(
+
+ );
+
+ const canvas = container.querySelector('canvas');
+ expect(canvas).toBeTruthy();
+ expect(chart).toBeTruthy();
+
+ if (canvas && chart) {
+ const event = new MouseEvent('click', {
+ clientX: 100,
+ clientY: 100,
+ bubbles: true,
+ });
+
+ Object.defineProperty(event, 'nativeEvent', {
+ value: event,
+ writable: false,
+ });
+
+ const elements = getDatasetAtEvent(chart, event as any);
+
+ expect(Array.isArray(elements)).toBe(true);
+ }
+ });
+
+ it('should call getElementsAtEventForMode with correct parameters', () => {
+ const { container } = render(
+
+ );
+
+ const canvas = container.querySelector('canvas');
+
+ if (canvas && chart) {
+ const spy = vi.spyOn(chart, 'getElementsAtEventForMode');
+
+ const event = new MouseEvent('click', {
+ clientX: 100,
+ clientY: 100,
+ bubbles: true,
+ });
+
+ Object.defineProperty(event, 'nativeEvent', {
+ value: event,
+ writable: false,
+ });
+
+ getDatasetAtEvent(chart, event as any);
+
+ expect(spy).toHaveBeenCalledWith(
+ event,
+ 'dataset',
+ { intersect: true },
+ false
+ );
+ }
+ });
+ });
+
+ describe('getElementAtEvent', () => {
+ it('should return single element at event location', () => {
+ const { container } = render(
+
+ );
+
+ const canvas = container.querySelector('canvas');
+ expect(canvas).toBeTruthy();
+ expect(chart).toBeTruthy();
+
+ if (canvas && chart) {
+ const event = new MouseEvent('click', {
+ clientX: 100,
+ clientY: 100,
+ bubbles: true,
+ });
+
+ Object.defineProperty(event, 'nativeEvent', {
+ value: event,
+ writable: false,
+ });
+
+ const elements = getElementAtEvent(chart, event as any);
+
+ expect(Array.isArray(elements)).toBe(true);
+ }
+ });
+
+ it('should call getElementsAtEventForMode with nearest mode', () => {
+ const { container } = render(
+
+ );
+
+ const canvas = container.querySelector('canvas');
+
+ if (canvas && chart) {
+ const spy = vi.spyOn(chart, 'getElementsAtEventForMode');
+
+ const event = new MouseEvent('click', {
+ clientX: 100,
+ clientY: 100,
+ bubbles: true,
+ });
+
+ Object.defineProperty(event, 'nativeEvent', {
+ value: event,
+ writable: false,
+ });
+
+ getElementAtEvent(chart, event as any);
+
+ expect(spy).toHaveBeenCalledWith(
+ event,
+ 'nearest',
+ { intersect: true },
+ false
+ );
+ }
+ });
+ });
+
+ describe('getElementsAtEvent', () => {
+ it('should return all elements at event location', () => {
+ const { container } = render(
+
+ );
+
+ const canvas = container.querySelector('canvas');
+ expect(canvas).toBeTruthy();
+ expect(chart).toBeTruthy();
+
+ if (canvas && chart) {
+ const event = new MouseEvent('click', {
+ clientX: 100,
+ clientY: 100,
+ bubbles: true,
+ });
+
+ Object.defineProperty(event, 'nativeEvent', {
+ value: event,
+ writable: false,
+ });
+
+ const elements = getElementsAtEvent(chart, event as any);
+
+ expect(Array.isArray(elements)).toBe(true);
+ }
+ });
+
+ it('should call getElementsAtEventForMode with index mode', () => {
+ const { container } = render(
+
+ );
+
+ const canvas = container.querySelector('canvas');
+
+ if (canvas && chart) {
+ const spy = vi.spyOn(chart, 'getElementsAtEventForMode');
+
+ const event = new MouseEvent('click', {
+ clientX: 100,
+ clientY: 100,
+ bubbles: true,
+ });
+
+ Object.defineProperty(event, 'nativeEvent', {
+ value: event,
+ writable: false,
+ });
+
+ getElementsAtEvent(chart, event as any);
+
+ expect(spy).toHaveBeenCalledWith(
+ event,
+ 'index',
+ { intersect: true },
+ false
+ );
+ }
+ });
+ });
+ });
+});