diff --git a/.changeset/initial-release.md b/.changeset/initial-release.md
new file mode 100644
index 0000000..bae87b5
--- /dev/null
+++ b/.changeset/initial-release.md
@@ -0,0 +1,12 @@
+---
+'@ciscode/ui-chart-kit': minor
+---
+
+Initial release of @ciscode/ui-chart-kit v0.1.0.
+
+- ChartDataPoint, ChartDataset, and ChartTheme type contracts
+- buildChartConfig utility mapping typed data to Chart.js config
+- BarChart component with stacked and horizontal support
+- LineChart component with smooth curve support
+- AreaChart component with fill at 20% opacity and stacked support
+- All components responsive with configurable height
diff --git a/README.md b/README.md
index 539fe85..d2fcecb 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,170 @@
-# React TypeScript DeveloperKit (Template)
+# @ciscode/ui-chart-kit
-Template repository for building reusable React TypeScript **npm libraries**
-(components + hooks + utilities).
+Typed React chart components (Bar, Line, Area) built on Chart.js.
+Pass data and a theme — get a fully configured, responsive chart. No raw Chart.js options required.
-## What you get
+## Installation
-- ESM + CJS + Types build (tsup)
-- Vitest testing
-- ESLint + Prettier (flat config)
-- Changesets (manual release flow, no automation PR)
-- Husky (pre-commit + pre-push)
-- Enforced public API via `src/index.ts`
-- Dependency-free styling (Tailwind-compatible by convention only)
-- `react` and `react-dom` as peerDependencies
+```bash
+npm install @ciscode/ui-chart-kit
+```
-## Package structure
+### Peer dependencies
-- `src/components` – reusable UI components
-- `src/hooks` – reusable React hooks
-- `src/utils` – framework-agnostic utilities
-- `src/index.ts` – **only public API** (no deep imports allowed)
+| Package | Version |
+| ----------- | ------- |
+| `react` | ≥ 18 |
+| `react-dom` | ≥ 18 |
-Anything not exported from `src/index.ts` is considered private.
+`chart.js` and `react-chartjs-2` are bundled — you do **not** need to install them separately.
-## Scripts
+---
-- `npm run build` – build to `dist/` (tsup)
-- `npm test` – run tests (vitest)
-- `npm run typecheck` – TypeScript typecheck
-- `npm run lint` – ESLint
-- `npm run format` / `npm run format:write` – Prettier
-- `npx changeset` – create a changeset
+## Data types
-## Release flow (summary)
+### `ChartDataPoint`
-- Work on a `feature` branch from `develop`
-- Merge to `develop`
-- Add a changeset for user-facing changes: `npx changeset`
-- Promote `develop` → `master`
-- Tag `vX.Y.Z` to publish (npm OIDC)
+```ts
+interface ChartDataPoint {
+ label: string;
+ value: number;
+}
+```
-This repository is a **template**. Teams should clone it and focus only on
-library logic, not tooling or release mechanics.
+### `ChartDataset`
+
+```ts
+interface ChartDataset {
+ id: string;
+ label: string;
+ data: ChartDataPoint[];
+ color?: string; // hex color — falls back to theme.colors when omitted
+}
+```
+
+### `ChartTheme`
+
+```ts
+interface ChartTheme {
+ colors: string[]; // palette shared across datasets
+ fontFamily?: string;
+ fontSize?: number;
+ grid?: {
+ color?: string;
+ display?: boolean;
+ };
+ tooltip?: {
+ enabled?: boolean;
+ backgroundColor?: string;
+ titleColor?: string;
+ bodyColor?: string;
+ };
+ legend?: {
+ display?: boolean;
+ position?: 'top' | 'bottom' | 'left' | 'right';
+ };
+}
+```
+
+---
+
+## Components
+
+### BarChart
+
+| Prop | Type | Default | Description |
+| ------------ | ---------------- | ------- | --------------------------------- |
+| `data` | `ChartDataset[]` | — | Datasets to render |
+| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) |
+| `height` | `number` | `300` | Chart height in pixels |
+| `stacked` | `boolean` | `false` | Stack bars on top of each other |
+| `horizontal` | `boolean` | `false` | Render horizontal bars |
+
+```tsx
+import { BarChart } from '@ciscode/ui-chart-kit';
+import type { ChartDataset, ChartTheme } from '@ciscode/ui-chart-kit';
+
+const theme: ChartTheme = {
+ colors: ['#4F46E5', '#10B981', '#F59E0B'],
+ fontFamily: 'Inter, sans-serif',
+ fontSize: 12,
+ grid: { color: '#E5E7EB', display: true },
+ tooltip: { enabled: true, backgroundColor: '#1F2937' },
+ legend: { display: true, position: 'top' },
+};
+
+const datasets: ChartDataset[] = [
+ {
+ id: 'revenue',
+ label: 'Revenue',
+ data: [
+ { label: 'Q1', value: 120 },
+ { label: 'Q2', value: 180 },
+ { label: 'Q3', value: 150 },
+ { label: 'Q4', value: 210 },
+ ],
+ },
+];
+
+function App() {
+ return ;
+}
+```
+
+---
+
+### LineChart
+
+| Prop | Type | Default | Description |
+| -------- | ---------------- | ------- | --------------------------------------- |
+| `data` | `ChartDataset[]` | — | Datasets to render |
+| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) |
+| `height` | `number` | `300` | Chart height in pixels |
+| `smooth` | `boolean` | `false` | Curved line interpolation (0.4 tension) |
+
+```tsx
+import { LineChart } from '@ciscode/ui-chart-kit';
+
+function App() {
+ return ;
+}
+```
+
+---
+
+### AreaChart
+
+| Prop | Type | Default | Description |
+| --------- | ---------------- | ------- | --------------------------------------- |
+| `data` | `ChartDataset[]` | — | Datasets to render |
+| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) |
+| `height` | `number` | `300` | Chart height in pixels |
+| `smooth` | `boolean` | `false` | Curved line interpolation (0.4 tension) |
+| `stacked` | `boolean` | `false` | Stack areas on top of each other |
+
+Area fill uses the dataset color at 20 % opacity automatically.
+
+```tsx
+import { AreaChart } from '@ciscode/ui-chart-kit';
+
+function App() {
+ return ;
+}
+```
+
+---
+
+## Design decisions
+
+- **No Chart.js passthrough.** Components expose a curated props API only.
+ Chart.js configuration is built internally via `buildChartConfig`.
+ This keeps the public surface small and prevents breaking changes
+ when Chart.js internals evolve.
+- **Colors cycle.** When there are more datasets than `theme.colors` entries,
+ colors wrap around automatically.
+- **Responsive by default.** Every chart renders inside a `div` with
+ `width: 100%` and the specified `height`.
+
+## License
+
+MIT
diff --git a/ciscode-reactts-developerkit-1.0.0.tgz b/ciscode-reactts-developerkit-1.0.0.tgz
new file mode 100644
index 0000000..47778b6
Binary files /dev/null and b/ciscode-reactts-developerkit-1.0.0.tgz differ
diff --git a/package-lock.json b/package-lock.json
index 065e5b9..1599bb6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,10 @@
"name": "@ciscode/ui-chart-kit",
"version": "0.0.0",
"license": "MIT",
+ "dependencies": {
+ "chart.js": "^4.5.1",
+ "react-chartjs-2": "^5.3.1"
+ },
"devDependencies": {
"@changesets/cli": "^2.27.8",
"@eslint/js": "^9.39.2",
@@ -158,6 +162,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -779,6 +784,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -819,6 +825,7 @@
}
],
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=20.19.0"
}
@@ -1736,6 +1743,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
"node_modules/@manypkg/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz",
@@ -2309,8 +2322,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
@@ -2339,6 +2351,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2350,6 +2363,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -2389,6 +2403,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -2724,6 +2739,7 @@
"integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/utils": "2.1.9",
"fflate": "^0.8.2",
@@ -2761,6 +2777,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3157,6 +3174,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3319,6 +3337,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/chart.js": {
+ "version": "4.5.1",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
+ "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@@ -3712,8 +3743,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -3982,6 +4012,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
+ "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -4046,6 +4077,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5838,6 +5870,7 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",
@@ -6217,7 +6250,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -6975,6 +7007,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7059,7 +7092,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -7075,7 +7107,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -7160,6 +7191,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-chartjs-2": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
+ "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
@@ -7178,8 +7219,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/read-yaml-file": {
"version": "1.1.0",
@@ -7556,8 +7596,7 @@
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
@@ -8376,6 +8415,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -8662,6 +8702,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8787,6 +8828,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -9300,6 +9342,7 @@
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
@@ -9710,6 +9753,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 2502981..7ef117a 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@ciscode/ui-chart-kit",
- "version": "0.0.0",
- "description": "React 19 chart library on Chart.js.",
+ "version": "0.1.0",
+ "description": "Typed React chart components (Bar, Line, Area) built on Chart.js — no raw Chart.js config required.",
"license": "MIT",
"private": false,
"type": "module",
@@ -84,5 +84,9 @@
},
"engines": {
"node": ">=20"
+ },
+ "dependencies": {
+ "chart.js": "^4.5.1",
+ "react-chartjs-2": "^5.3.1"
}
}
diff --git a/src/components/AreaChart/AreaChart.test.tsx b/src/components/AreaChart/AreaChart.test.tsx
new file mode 100644
index 0000000..72fba88
--- /dev/null
+++ b/src/components/AreaChart/AreaChart.test.tsx
@@ -0,0 +1,184 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import type { ChartDataset, ChartTheme } from '../../types/chart.types';
+
+vi.mock('react-chartjs-2', () => ({
+ Line: (props: { data: unknown; options: unknown }) => (
+
+ ),
+}));
+
+vi.mock('chart.js', () => ({
+ Chart: { register: vi.fn() },
+ CategoryScale: 'CategoryScale',
+ LinearScale: 'LinearScale',
+ LineElement: 'LineElement',
+ PointElement: 'PointElement',
+ Filler: 'Filler',
+ Tooltip: 'Tooltip',
+ Legend: 'Legend',
+}));
+
+import { AreaChart } from './AreaChart';
+
+const singleDataset: ChartDataset[] = [
+ {
+ id: 'ds1',
+ label: 'Sales',
+ data: [
+ { label: 'Jan', value: 10 },
+ { label: 'Feb', value: 20 },
+ ],
+ },
+];
+
+const multiDatasets: ChartDataset[] = [
+ {
+ id: 'ds1',
+ label: 'Sales',
+ data: [
+ { label: 'Jan', value: 10 },
+ { label: 'Feb', value: 20 },
+ ],
+ },
+ {
+ id: 'ds2',
+ label: 'Revenue',
+ data: [
+ { label: 'Jan', value: 30 },
+ { label: 'Feb', value: 40 },
+ ],
+ color: '#00FF00',
+ },
+ {
+ id: 'ds3',
+ label: 'Costs',
+ data: [
+ { label: 'Jan', value: 5 },
+ { label: 'Feb', value: 15 },
+ ],
+ },
+];
+
+const theme: ChartTheme = {
+ colors: ['#FF0000', '#00FF00', '#0000FF'],
+ fontFamily: 'Arial',
+ fontSize: 14,
+ grid: { color: '#ccc', display: true },
+ tooltip: { enabled: true, backgroundColor: '#333' },
+ legend: { display: true, position: 'bottom' },
+};
+
+describe('AreaChart', () => {
+ it('should render with 1 dataset without errors', () => {
+ render();
+ expect(screen.getByTestId('area-canvas')).toBeInTheDocument();
+ });
+
+ it('should render with multiple datasets without errors', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.datasets).toHaveLength(3);
+ });
+
+ it('should set fill true on all datasets (area variant)', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ for (const ds of data.datasets) {
+ expect(ds.fill).toBe(true);
+ }
+ });
+
+ it('should apply 20% opacity (append 33) to backgroundColor', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.datasets[0].backgroundColor).toBe('#FF000033');
+ });
+
+ it('should use dataset color with 20% opacity when color is provided', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.datasets[1].backgroundColor).toBe('#00FF0033');
+ });
+
+ it('should wrap chart in div with width 100% and default height 300', () => {
+ const { container } = render();
+ const wrapper = container.firstElementChild as HTMLDivElement;
+ expect(wrapper.style.width).toBe('100%');
+ expect(wrapper.style.height).toBe('300px');
+ });
+
+ it('should apply custom height', () => {
+ const { container } = render();
+ const wrapper = container.firstElementChild as HTMLDivElement;
+ expect(wrapper.style.height).toBe('450px');
+ });
+
+ it('should set tension 0.4 on all datasets when smooth is true', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ for (const ds of data.datasets) {
+ expect(ds.tension).toBe(0.4);
+ }
+ });
+
+ it('should not set tension when smooth is false', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.datasets[0].tension).toBeUndefined();
+ });
+
+ it('should set scales.y.stacked true when stacked is true', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.scales.y.stacked).toBe(true);
+ });
+
+ it('should not set stacked when stacked prop is false', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.scales.y.stacked).toBeUndefined();
+ });
+
+ it('should support stacked and smooth together', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(options.scales.y.stacked).toBe(true);
+ for (const ds of data.datasets) {
+ expect(ds.tension).toBe(0.4);
+ expect(ds.fill).toBe(true);
+ }
+ });
+
+ it('should reflect theme settings in options', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.plugins.tooltip.enabled).toBe(true);
+ expect(options.plugins.legend.position).toBe('bottom');
+ expect(options.responsive).toBe(true);
+ });
+
+ it('should handle empty data array without crash', () => {
+ render();
+ const canvas = screen.getByTestId('area-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.labels).toEqual([]);
+ expect(data.datasets).toEqual([]);
+ });
+});
diff --git a/src/components/AreaChart/AreaChart.tsx b/src/components/AreaChart/AreaChart.tsx
new file mode 100644
index 0000000..74c2741
--- /dev/null
+++ b/src/components/AreaChart/AreaChart.tsx
@@ -0,0 +1,61 @@
+import React, { useMemo } from 'react';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ LineElement,
+ PointElement,
+ Filler,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import { Line } from 'react-chartjs-2';
+import { buildChartConfig } from '../../utils/buildChartConfig';
+import type { AreaChartProps } from './AreaChart.types';
+
+ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend);
+
+/**
+ * AreaChart renders a filled line chart using react-chartjs-2.
+ * Uses the 'area' variant from buildChartConfig for fill with 20% opacity.
+ * Supports smooth curves and stacked mode.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export const AreaChart: React.FC = ({
+ data,
+ theme,
+ height = 300,
+ smooth = false,
+ stacked = false,
+}) => {
+ const config = useMemo(() => {
+ const base = buildChartConfig(data, theme, 'area');
+
+ if (smooth) {
+ for (const ds of base.data.datasets) {
+ ds.tension = 0.4;
+ }
+ }
+
+ if (stacked) {
+ base.options.scales.y.stacked = true;
+ }
+
+ return base;
+ }, [data, theme, smooth, stacked]);
+
+ return (
+
+ ['options']}
+ />
+
+ );
+};
+
+AreaChart.displayName = 'AreaChart';
diff --git a/src/components/AreaChart/AreaChart.types.ts b/src/components/AreaChart/AreaChart.types.ts
new file mode 100644
index 0000000..096f8b0
--- /dev/null
+++ b/src/components/AreaChart/AreaChart.types.ts
@@ -0,0 +1,14 @@
+import type { ChartDataset, ChartTheme } from '../../types/chart.types';
+
+export interface AreaChartProps {
+ /** Array of datasets to render */
+ data: ChartDataset[];
+ /** Theme configuration for colors, fonts, grid, tooltip, and legend */
+ theme: ChartTheme;
+ /** Chart height in pixels @default 300 */
+ height?: number;
+ /** Apply curved line interpolation (tension 0.4) @default false */
+ smooth?: boolean;
+ /** Stack area datasets on top of each other */
+ stacked?: boolean;
+}
diff --git a/src/components/AreaChart/index.ts b/src/components/AreaChart/index.ts
new file mode 100644
index 0000000..75803cd
--- /dev/null
+++ b/src/components/AreaChart/index.ts
@@ -0,0 +1,2 @@
+export { AreaChart } from './AreaChart';
+export type { AreaChartProps } from './AreaChart.types';
diff --git a/src/components/BarChart/BarChart.test.tsx b/src/components/BarChart/BarChart.test.tsx
new file mode 100644
index 0000000..e845ee3
--- /dev/null
+++ b/src/components/BarChart/BarChart.test.tsx
@@ -0,0 +1,166 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import type { ChartDataset, ChartTheme } from '../../types/chart.types';
+
+// Mock react-chartjs-2 to avoid canvas dependency in jsdom
+vi.mock('react-chartjs-2', () => ({
+ Bar: (props: { data: unknown; options: unknown }) => (
+
+ ),
+}));
+
+// Mock chart.js register to avoid side-effects
+vi.mock('chart.js', () => ({
+ Chart: { register: vi.fn() },
+ CategoryScale: 'CategoryScale',
+ LinearScale: 'LinearScale',
+ BarElement: 'BarElement',
+ Tooltip: 'Tooltip',
+ Legend: 'Legend',
+}));
+
+import { BarChart } from './BarChart';
+
+const sampleData: ChartDataset[] = [
+ {
+ id: 'ds1',
+ label: 'Sales',
+ data: [
+ { label: 'Jan', value: 10 },
+ { label: 'Feb', value: 20 },
+ ],
+ },
+];
+
+const threeDatasets: ChartDataset[] = [
+ {
+ id: 'ds1',
+ label: 'Sales',
+ data: [
+ { label: 'Jan', value: 10 },
+ { label: 'Feb', value: 20 },
+ ],
+ },
+ {
+ id: 'ds2',
+ label: 'Revenue',
+ data: [
+ { label: 'Jan', value: 30 },
+ { label: 'Feb', value: 40 },
+ ],
+ color: '#00FF00',
+ },
+ {
+ id: 'ds3',
+ label: 'Costs',
+ data: [
+ { label: 'Jan', value: 5 },
+ { label: 'Feb', value: 15 },
+ ],
+ },
+];
+
+const theme: ChartTheme = {
+ colors: ['#FF0000', '#00FF00', '#0000FF'],
+ fontFamily: 'Arial',
+ fontSize: 14,
+ grid: { color: '#ccc', display: true },
+ tooltip: { enabled: true, backgroundColor: '#333' },
+ legend: { display: true, position: 'bottom' },
+};
+
+describe('BarChart', () => {
+ it('should render with 1 dataset without errors', () => {
+ render();
+ expect(screen.getByTestId('bar-canvas')).toBeInTheDocument();
+ });
+
+ it('should render with 3 datasets without errors', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.datasets).toHaveLength(3);
+ });
+
+ it('should wrap chart in div with width 100% and default height 300', () => {
+ const { container } = render();
+ const wrapper = container.firstElementChild as HTMLDivElement;
+ expect(wrapper.style.width).toBe('100%');
+ expect(wrapper.style.height).toBe('300px');
+ });
+
+ it('should apply custom height', () => {
+ const { container } = render();
+ const wrapper = container.firstElementChild as HTMLDivElement;
+ expect(wrapper.style.height).toBe('500px');
+ });
+
+ it('should set stacked on both axes when stacked prop is true', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.scales.x.stacked).toBe(true);
+ expect(options.scales.y.stacked).toBe(true);
+ });
+
+ it('should not set stacked when stacked prop is false', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.scales.x.stacked).toBeUndefined();
+ expect(options.scales.y.stacked).toBeUndefined();
+ });
+
+ it('should set indexAxis to y when horizontal is true', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.indexAxis).toBe('y');
+ });
+
+ it('should not set indexAxis when horizontal is false', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.indexAxis).toBeUndefined();
+ });
+
+ it('should pass animation enabled by default (no explicit disable)', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.animation).toBeUndefined();
+ });
+
+ it('should reflect theme tooltip and legend settings in options', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.plugins.tooltip.enabled).toBe(true);
+ expect(options.plugins.tooltip.backgroundColor).toBe('#333');
+ expect(options.plugins.legend.display).toBe(true);
+ expect(options.plugins.legend.position).toBe('bottom');
+ });
+
+ it('should support stacked and horizontal together', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.scales.x.stacked).toBe(true);
+ expect(options.scales.y.stacked).toBe(true);
+ expect(options.indexAxis).toBe('y');
+ });
+
+ it('should handle empty data array without crash', () => {
+ render();
+ const canvas = screen.getByTestId('bar-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.labels).toEqual([]);
+ expect(data.datasets).toEqual([]);
+ });
+});
diff --git a/src/components/BarChart/BarChart.tsx b/src/components/BarChart/BarChart.tsx
new file mode 100644
index 0000000..03d7d47
--- /dev/null
+++ b/src/components/BarChart/BarChart.tsx
@@ -0,0 +1,57 @@
+import React, { useMemo } from 'react';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import { Bar } from 'react-chartjs-2';
+import { buildChartConfig } from '../../utils/buildChartConfig';
+import type { BarChartProps } from './BarChart.types';
+
+ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);
+
+/**
+ * BarChart renders a bar chart using react-chartjs-2.
+ * Supports stacked and horizontal variants via typed props only.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export const BarChart: React.FC = ({
+ data,
+ theme,
+ height = 300,
+ stacked = false,
+ horizontal = false,
+}) => {
+ const config = useMemo(() => {
+ const base = buildChartConfig(data, theme, 'bar');
+
+ if (stacked) {
+ base.options.scales.x.stacked = true;
+ base.options.scales.y.stacked = true;
+ }
+
+ if (horizontal) {
+ base.options.indexAxis = 'y';
+ }
+
+ return base;
+ }, [data, theme, stacked, horizontal]);
+
+ return (
+
+ ['options']}
+ />
+
+ );
+};
+
+BarChart.displayName = 'BarChart';
diff --git a/src/components/BarChart/BarChart.types.ts b/src/components/BarChart/BarChart.types.ts
new file mode 100644
index 0000000..0a6db58
--- /dev/null
+++ b/src/components/BarChart/BarChart.types.ts
@@ -0,0 +1,14 @@
+import type { ChartDataset, ChartTheme } from '../../types/chart.types';
+
+export interface BarChartProps {
+ /** Array of datasets to render */
+ data: ChartDataset[];
+ /** Theme configuration for colors, fonts, grid, tooltip, and legend */
+ theme: ChartTheme;
+ /** Chart height in pixels @default 300 */
+ height?: number;
+ /** Stack bars on top of each other */
+ stacked?: boolean;
+ /** Render horizontal bars (indexAxis 'y') */
+ horizontal?: boolean;
+}
diff --git a/src/components/BarChart/index.ts b/src/components/BarChart/index.ts
new file mode 100644
index 0000000..e9b7e02
--- /dev/null
+++ b/src/components/BarChart/index.ts
@@ -0,0 +1,2 @@
+export { BarChart } from './BarChart';
+export type { BarChartProps } from './BarChart.types';
diff --git a/src/components/LineChart/LineChart.test.tsx b/src/components/LineChart/LineChart.test.tsx
new file mode 100644
index 0000000..6529a5b
--- /dev/null
+++ b/src/components/LineChart/LineChart.test.tsx
@@ -0,0 +1,142 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import type { ChartDataset, ChartTheme } from '../../types/chart.types';
+
+vi.mock('react-chartjs-2', () => ({
+ Line: (props: { data: unknown; options: unknown }) => (
+
+ ),
+}));
+
+vi.mock('chart.js', () => ({
+ Chart: { register: vi.fn() },
+ CategoryScale: 'CategoryScale',
+ LinearScale: 'LinearScale',
+ LineElement: 'LineElement',
+ PointElement: 'PointElement',
+ Filler: 'Filler',
+ Tooltip: 'Tooltip',
+ Legend: 'Legend',
+}));
+
+import { LineChart } from './LineChart';
+
+const singleDataset: ChartDataset[] = [
+ {
+ id: 'ds1',
+ label: 'Sales',
+ data: [
+ { label: 'Jan', value: 10 },
+ { label: 'Feb', value: 20 },
+ ],
+ },
+];
+
+const multiDatasets: ChartDataset[] = [
+ {
+ id: 'ds1',
+ label: 'Sales',
+ data: [
+ { label: 'Jan', value: 10 },
+ { label: 'Feb', value: 20 },
+ ],
+ },
+ {
+ id: 'ds2',
+ label: 'Revenue',
+ data: [
+ { label: 'Jan', value: 30 },
+ { label: 'Feb', value: 40 },
+ ],
+ color: '#00FF00',
+ },
+ {
+ id: 'ds3',
+ label: 'Costs',
+ data: [
+ { label: 'Jan', value: 5 },
+ { label: 'Feb', value: 15 },
+ ],
+ },
+];
+
+const theme: ChartTheme = {
+ colors: ['#FF0000', '#00FF00', '#0000FF'],
+ fontFamily: 'Arial',
+ fontSize: 14,
+ grid: { color: '#ccc', display: true },
+ tooltip: { enabled: true, backgroundColor: '#333' },
+ legend: { display: true, position: 'bottom' },
+};
+
+describe('LineChart', () => {
+ it('should render with 1 dataset without errors', () => {
+ render();
+ expect(screen.getByTestId('line-canvas')).toBeInTheDocument();
+ });
+
+ it('should render with multiple datasets without errors', () => {
+ render();
+ const canvas = screen.getByTestId('line-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.datasets).toHaveLength(3);
+ });
+
+ it('should wrap chart in div with width 100% and default height 300', () => {
+ const { container } = render();
+ const wrapper = container.firstElementChild as HTMLDivElement;
+ expect(wrapper.style.width).toBe('100%');
+ expect(wrapper.style.height).toBe('300px');
+ });
+
+ it('should apply custom height', () => {
+ const { container } = render();
+ const wrapper = container.firstElementChild as HTMLDivElement;
+ expect(wrapper.style.height).toBe('500px');
+ });
+
+ it('should not set tension when smooth is false', () => {
+ render();
+ const canvas = screen.getByTestId('line-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.datasets[0].tension).toBeUndefined();
+ });
+
+ it('should set tension 0.4 on all datasets when smooth is true', () => {
+ render();
+ const canvas = screen.getByTestId('line-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ for (const ds of data.datasets) {
+ expect(ds.tension).toBe(0.4);
+ }
+ });
+
+ it('should not set fill for line variant', () => {
+ render();
+ const canvas = screen.getByTestId('line-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.datasets[0].fill).toBeUndefined();
+ });
+
+ it('should reflect theme settings in options', () => {
+ render();
+ const canvas = screen.getByTestId('line-canvas');
+ const options = JSON.parse(canvas.getAttribute('data-options')!);
+ expect(options.plugins.tooltip.enabled).toBe(true);
+ expect(options.plugins.legend.position).toBe('bottom');
+ expect(options.responsive).toBe(true);
+ });
+
+ it('should handle empty data array without crash', () => {
+ render();
+ const canvas = screen.getByTestId('line-canvas');
+ const data = JSON.parse(canvas.getAttribute('data-data')!);
+ expect(data.labels).toEqual([]);
+ expect(data.datasets).toEqual([]);
+ });
+});
diff --git a/src/components/LineChart/LineChart.tsx b/src/components/LineChart/LineChart.tsx
new file mode 100644
index 0000000..b8e2f8d
--- /dev/null
+++ b/src/components/LineChart/LineChart.tsx
@@ -0,0 +1,55 @@
+import React, { useMemo } from 'react';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ LineElement,
+ PointElement,
+ Filler,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import { Line } from 'react-chartjs-2';
+import { buildChartConfig } from '../../utils/buildChartConfig';
+import type { LineChartProps } from './LineChart.types';
+
+ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend);
+
+/**
+ * LineChart renders a line chart using react-chartjs-2.
+ * Supports smooth curves via the smooth prop.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export const LineChart: React.FC = ({
+ data,
+ theme,
+ height = 300,
+ smooth = false,
+}) => {
+ const config = useMemo(() => {
+ const base = buildChartConfig(data, theme, 'line');
+
+ if (smooth) {
+ for (const ds of base.data.datasets) {
+ ds.tension = 0.4;
+ }
+ }
+
+ return base;
+ }, [data, theme, smooth]);
+
+ return (
+
+ ['options']}
+ />
+
+ );
+};
+
+LineChart.displayName = 'LineChart';
diff --git a/src/components/LineChart/LineChart.types.ts b/src/components/LineChart/LineChart.types.ts
new file mode 100644
index 0000000..6e62173
--- /dev/null
+++ b/src/components/LineChart/LineChart.types.ts
@@ -0,0 +1,12 @@
+import type { ChartDataset, ChartTheme } from '../../types/chart.types';
+
+export interface LineChartProps {
+ /** Array of datasets to render */
+ data: ChartDataset[];
+ /** Theme configuration for colors, fonts, grid, tooltip, and legend */
+ theme: ChartTheme;
+ /** Chart height in pixels @default 300 */
+ height?: number;
+ /** Apply curved line interpolation (tension 0.4) @default false */
+ smooth?: boolean;
+}
diff --git a/src/components/LineChart/index.ts b/src/components/LineChart/index.ts
new file mode 100644
index 0000000..b22f5c7
--- /dev/null
+++ b/src/components/LineChart/index.ts
@@ -0,0 +1,2 @@
+export { LineChart } from './LineChart';
+export type { LineChartProps } from './LineChart.types';
diff --git a/src/components/index.ts b/src/components/index.ts
index 52f8fa8..d5b415c 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -2,3 +2,6 @@
export const __components_placeholder = true;
export * from './NoopButton';
+export * from './BarChart';
+export * from './LineChart';
+export * from './AreaChart';
diff --git a/src/index.ts b/src/index.ts
index c55977d..14b0c36 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
export * from './components';
export * from './hooks';
+export * from './types';
export * from './utils';
diff --git a/src/types/chart.types.ts b/src/types/chart.types.ts
new file mode 100644
index 0000000..039a100
--- /dev/null
+++ b/src/types/chart.types.ts
@@ -0,0 +1,70 @@
+export interface ChartDataPoint {
+ label: string;
+ value: number;
+}
+
+export interface ChartDataset {
+ id: string;
+ label: string;
+ data: ChartDataPoint[];
+ color?: string;
+}
+
+export interface ChartTheme {
+ colors: string[];
+ fontFamily?: string;
+ fontSize?: number;
+ grid?: {
+ color?: string;
+ display?: boolean;
+ };
+ tooltip?: {
+ enabled?: boolean;
+ backgroundColor?: string;
+ titleColor?: string;
+ bodyColor?: string;
+ };
+ legend?: {
+ display?: boolean;
+ position?: 'top' | 'bottom' | 'left' | 'right';
+ };
+}
+
+export type ChartVariant = 'bar' | 'line' | 'area';
+
+export interface ChartConfig {
+ type: 'bar' | 'line';
+ data: {
+ labels: string[];
+ datasets: ChartConfigDataset[];
+ };
+ options: {
+ responsive: boolean;
+ indexAxis?: 'x' | 'y';
+ plugins: {
+ tooltip: ChartTheme['tooltip'] & Record;
+ legend: ChartTheme['legend'] & Record;
+ };
+ scales: {
+ x: {
+ grid: ChartTheme['grid'] & Record;
+ ticks: Record;
+ stacked?: boolean;
+ };
+ y: {
+ grid: ChartTheme['grid'] & Record;
+ ticks: Record;
+ stacked?: boolean;
+ };
+ };
+ };
+}
+
+export interface ChartConfigDataset {
+ label: string;
+ data: number[];
+ backgroundColor: string | string[];
+ borderColor: string;
+ fill?: boolean;
+ tension?: number;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..9613f86
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1 @@
+export * from './chart.types';
diff --git a/src/utils/buildChartConfig.test.ts b/src/utils/buildChartConfig.test.ts
new file mode 100644
index 0000000..e06023d
--- /dev/null
+++ b/src/utils/buildChartConfig.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect } from 'vitest';
+import { buildChartConfig } from './buildChartConfig';
+import type { ChartDataset, ChartTheme } from '../types/chart.types';
+
+const sampleData = [
+ { label: 'Jan', value: 10 },
+ { label: 'Feb', value: 20 },
+ { label: 'Mar', value: 30 },
+];
+
+const baseTheme: ChartTheme = {
+ colors: ['#FF0000', '#00FF00', '#0000FF'],
+ fontFamily: 'Arial',
+ fontSize: 14,
+ grid: { color: '#E0E0E0', display: true },
+ tooltip: {
+ enabled: true,
+ backgroundColor: '#333',
+ titleColor: '#FFF',
+ bodyColor: '#CCC',
+ },
+ legend: { display: true, position: 'bottom' },
+};
+
+const datasets: ChartDataset[] = [
+ { id: 'ds1', label: 'Sales', data: sampleData },
+ { id: 'ds2', label: 'Revenue', data: sampleData, color: '#ABCDEF' },
+];
+
+describe('buildChartConfig', () => {
+ describe('bar variant', () => {
+ const config = buildChartConfig(datasets, baseTheme, 'bar');
+
+ it('should set type to bar', () => {
+ expect(config.type).toBe('bar');
+ });
+
+ it('should extract labels from the first dataset', () => {
+ expect(config.data.labels).toEqual(['Jan', 'Feb', 'Mar']);
+ });
+
+ it('should map dataset values', () => {
+ expect(config.data.datasets[0].data).toEqual([10, 20, 30]);
+ });
+
+ it('should fall back to theme.colors when dataset.color is undefined', () => {
+ expect(config.data.datasets[0].backgroundColor).toBe('#FF0000');
+ expect(config.data.datasets[0].borderColor).toBe('#FF0000');
+ });
+
+ it('should use dataset.color when provided', () => {
+ expect(config.data.datasets[1].backgroundColor).toBe('#ABCDEF');
+ expect(config.data.datasets[1].borderColor).toBe('#ABCDEF');
+ });
+
+ it('should not set fill for bar variant', () => {
+ expect(config.data.datasets[0].fill).toBeUndefined();
+ });
+ });
+
+ describe('line variant', () => {
+ const config = buildChartConfig(datasets, baseTheme, 'line');
+
+ it('should set type to line', () => {
+ expect(config.type).toBe('line');
+ });
+
+ it('should not set fill for line variant', () => {
+ expect(config.data.datasets[0].fill).toBeUndefined();
+ });
+ });
+
+ describe('area variant', () => {
+ const config = buildChartConfig(datasets, baseTheme, 'area');
+
+ it('should set type to line for area variant', () => {
+ expect(config.type).toBe('line');
+ });
+
+ it('should set fill true for area datasets', () => {
+ expect(config.data.datasets[0].fill).toBe(true);
+ expect(config.data.datasets[1].fill).toBe(true);
+ });
+
+ it('should append 33 to hex color for 20% opacity background', () => {
+ expect(config.data.datasets[0].backgroundColor).toBe('#FF000033');
+ });
+
+ it('should append 33 to explicit dataset color for area', () => {
+ expect(config.data.datasets[1].backgroundColor).toBe('#ABCDEF33');
+ });
+
+ it('should keep borderColor without opacity suffix', () => {
+ expect(config.data.datasets[0].borderColor).toBe('#FF0000');
+ });
+ });
+
+ describe('theme fields reflected in output', () => {
+ const config = buildChartConfig(datasets, baseTheme, 'bar');
+
+ it('should reflect tooltip settings', () => {
+ expect(config.options.plugins.tooltip.enabled).toBe(true);
+ expect(config.options.plugins.tooltip.backgroundColor).toBe('#333');
+ expect(config.options.plugins.tooltip.titleColor).toBe('#FFF');
+ expect(config.options.plugins.tooltip.bodyColor).toBe('#CCC');
+ });
+
+ it('should reflect legend settings', () => {
+ expect(config.options.plugins.legend.display).toBe(true);
+ expect(config.options.plugins.legend.position).toBe('bottom');
+ });
+
+ it('should reflect grid settings on both axes', () => {
+ expect(config.options.scales.x.grid.display).toBe(true);
+ expect(config.options.scales.x.grid.color).toBe('#E0E0E0');
+ expect(config.options.scales.y.grid.display).toBe(true);
+ expect(config.options.scales.y.grid.color).toBe('#E0E0E0');
+ });
+
+ it('should reflect fontFamily and fontSize in tick fonts', () => {
+ expect(config.options.scales.x.ticks).toEqual({ font: { family: 'Arial', size: 14 } });
+ expect(config.options.scales.y.ticks).toEqual({ font: { family: 'Arial', size: 14 } });
+ });
+
+ it('should set responsive true', () => {
+ expect(config.options.responsive).toBe(true);
+ });
+ });
+
+ describe('color cycling', () => {
+ it('should cycle through theme.colors when there are more datasets than colors', () => {
+ const threeDs: ChartDataset[] = [
+ { id: '1', label: 'A', data: sampleData },
+ { id: '2', label: 'B', data: sampleData },
+ { id: '3', label: 'C', data: sampleData },
+ { id: '4', label: 'D', data: sampleData },
+ ];
+ const config = buildChartConfig(threeDs, baseTheme, 'bar');
+ expect(config.data.datasets[3].backgroundColor).toBe('#FF0000');
+ });
+ });
+
+ describe('empty datasets', () => {
+ it('should produce empty labels for empty datasets array', () => {
+ const config = buildChartConfig([], baseTheme, 'bar');
+ expect(config.data.labels).toEqual([]);
+ expect(config.data.datasets).toEqual([]);
+ });
+ });
+
+ describe('default theme values', () => {
+ const minTheme: ChartTheme = { colors: ['#111'] };
+ const config = buildChartConfig(datasets, minTheme, 'line');
+
+ it('should default tooltip.enabled to true', () => {
+ expect(config.options.plugins.tooltip.enabled).toBe(true);
+ });
+
+ it('should default legend.display to true and position to top', () => {
+ expect(config.options.plugins.legend.display).toBe(true);
+ expect(config.options.plugins.legend.position).toBe('top');
+ });
+
+ it('should default grid.display to true', () => {
+ expect(config.options.scales.x.grid.display).toBe(true);
+ });
+ });
+});
diff --git a/src/utils/buildChartConfig.ts b/src/utils/buildChartConfig.ts
new file mode 100644
index 0000000..0197b1c
--- /dev/null
+++ b/src/utils/buildChartConfig.ts
@@ -0,0 +1,88 @@
+import type {
+ ChartConfig,
+ ChartConfigDataset,
+ ChartDataset,
+ ChartTheme,
+ ChartVariant,
+} from '../types/chart.types';
+
+function resolveColor(dataset: ChartDataset, index: number, theme: ChartTheme): string {
+ return dataset.color ?? theme.colors[index % theme.colors.length];
+}
+
+function applyAreaOpacity(hex: string): string {
+ return `${hex}33`;
+}
+
+function buildDataset(
+ dataset: ChartDataset,
+ index: number,
+ theme: ChartTheme,
+ variant: ChartVariant,
+): ChartConfigDataset {
+ const color = resolveColor(dataset, index, theme);
+
+ const base: ChartConfigDataset = {
+ label: dataset.label,
+ data: dataset.data.map((point) => point.value),
+ backgroundColor: variant === 'area' ? applyAreaOpacity(color) : color,
+ borderColor: color,
+ };
+
+ if (variant === 'area') {
+ base.fill = true;
+ }
+
+ return base;
+}
+
+export function buildChartConfig(
+ datasets: ChartDataset[],
+ theme: ChartTheme,
+ variant: ChartVariant,
+): ChartConfig {
+ const labels = datasets.length > 0 ? datasets[0].data.map((point) => point.label) : [];
+
+ const tickFont: Record = {};
+ if (theme.fontFamily) tickFont.family = theme.fontFamily;
+ if (theme.fontSize) tickFont.size = theme.fontSize;
+
+ return {
+ type: variant === 'area' ? 'line' : variant,
+ data: {
+ labels,
+ datasets: datasets.map((ds, i) => buildDataset(ds, i, theme, variant)),
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ tooltip: {
+ enabled: theme.tooltip?.enabled ?? true,
+ backgroundColor: theme.tooltip?.backgroundColor,
+ titleColor: theme.tooltip?.titleColor,
+ bodyColor: theme.tooltip?.bodyColor,
+ },
+ legend: {
+ display: theme.legend?.display ?? true,
+ position: theme.legend?.position ?? 'top',
+ },
+ },
+ scales: {
+ x: {
+ grid: {
+ display: theme.grid?.display ?? true,
+ color: theme.grid?.color,
+ },
+ ticks: { font: tickFont },
+ },
+ y: {
+ grid: {
+ display: theme.grid?.display ?? true,
+ color: theme.grid?.color,
+ },
+ ticks: { font: tickFont },
+ },
+ },
+ },
+ };
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 6211c64..5cdf899 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -2,3 +2,4 @@
export const __utils_placeholder = true;
export * from './noop';
+export * from './buildChartConfig';