├── .eslintrc.cjs
├── .github
└── workflows
│ ├── gh-pages.yaml
│ └── tests.yaml
├── .gitignore
├── .npmignore
├── .prettierignore
├── .prettierrc.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── env.d.ts
├── index.html
├── package-lock.json
├── package.json
├── public
└── favicon.png
├── src
├── App.vue
├── components
│ ├── DonutChart.vue
│ ├── DonutSections.vue
│ └── site
│ │ ├── ProductBadges.vue
│ │ └── ProjectDemo.vue
├── lib.ts
├── main.ts
├── styles
│ ├── main.css
│ ├── normalize.css
│ └── site.css
└── utils
│ ├── colors.ts
│ ├── misc.ts
│ └── types.ts
├── tests
├── unit
│ ├── donut.spec.ts
│ └── utils.spec.ts
└── utils.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.vitest.json
├── vite.config.mts
└── vitest.config.mts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | require('@rushstack/eslint-patch/modern-module-resolution');
3 |
4 | module.exports = {
5 | root: true,
6 | extends: [
7 | 'eslint:recommended',
8 |
9 | 'plugin:vue/vue3-essential',
10 | 'plugin:vue/vue3-strongly-recommended',
11 | 'plugin:vue/vue3-recommended',
12 |
13 | '@vue/eslint-config-typescript',
14 | '@vue/eslint-config-typescript/recommended',
15 |
16 | 'eslint-config-prettier',
17 | '@vue/eslint-config-prettier',
18 | ],
19 | parserOptions: {
20 | ecmaVersion: 'latest',
21 | },
22 | overrides: [
23 | {
24 | files: ['*.html'],
25 | rules: {
26 | 'vue/comment-directive': 'off',
27 | },
28 | },
29 | ],
30 | };
31 |
--------------------------------------------------------------------------------
/.github/workflows/gh-pages.yaml:
--------------------------------------------------------------------------------
1 | name: github-pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build-deploy:
10 | name: Build and deploy the demo site to GitHub Pages
11 | runs-on: ubuntu-22.04
12 | steps:
13 | - uses: actions/checkout@v4
14 |
15 | - name: Use Node.js 20
16 | uses: actions/setup-node@v4
17 | with:
18 | node-version: 20
19 | cache: 'npm'
20 |
21 | - run: npm install
22 |
23 | - name: Build
24 | run: npm run build:site
25 |
26 | - name: Deploy
27 | uses: peaceiris/actions-gh-pages@v4
28 | with:
29 | github_token: ${{ secrets.GITHUB_TOKEN }}
30 | keep_files: true
31 | publish_branch: gh-pages
32 | publish_dir: ./dist
33 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yaml:
--------------------------------------------------------------------------------
1 | name: tests
2 | on: [push, pull_request]
3 | jobs:
4 | lint_test_upload-coverage:
5 | name: Lint, test and upload coverage
6 | runs-on: ubuntu-22.04
7 | steps:
8 | - uses: actions/checkout@v4
9 |
10 | - name: Use Node.js 20
11 | uses: actions/setup-node@v4
12 | with:
13 | node-version: 20
14 | cache: 'npm'
15 |
16 | - run: npm install
17 |
18 | - name: Check for linting errors
19 | run: npm run lint
20 |
21 | - name: Run tests
22 | if: success()
23 | run: yarn test
24 |
25 | - name: Upload coverage
26 | if: success()
27 | run: bash <(curl -s https://codecov.io/bash) -t $TOKEN -B $REF
28 | env:
29 | TOKEN: "${{ secrets.CODECOV_TOKEN }}"
30 | REF: "${{ github.ref }}"
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | docs
2 |
3 | # Logs
4 | logs
5 | *.log
6 | npm-debug.log*
7 | yarn-debug.log*
8 | yarn-error.log*
9 | pnpm-debug.log*
10 | lerna-debug.log*
11 |
12 | node_modules
13 | .DS_Store
14 | dist
15 | dist-ssr
16 | coverage
17 | *.local
18 |
19 | /cypress/videos/
20 | /cypress/screenshots/
21 |
22 | # Editor directories and files
23 | .vscode/*
24 | !.vscode/extensions.json
25 | .idea
26 | *.suo
27 | *.ntvs*
28 | *.njsproj
29 | *.sln
30 | *.sw?
31 |
32 | *.tsbuildinfo
33 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | docs
2 | .github
3 | tests
4 | yarn.lock
5 | dist/assets
6 |
7 | # Logs
8 | logs
9 | *.log
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 | lerna-debug.log*
15 |
16 | node_modules
17 | .DS_Store
18 | dist
19 | dist-ssr
20 | coverage
21 | *.local
22 |
23 | /cypress/videos/
24 | /cypress/screenshots/
25 |
26 | # Editor directories and files
27 | .vscode/*
28 | !.vscode/extensions.json
29 | .idea
30 | *.suo
31 | *.ntvs*
32 | *.njsproj
33 | *.sln
34 | *.sw?
35 |
36 | *.tsbuildinfo
37 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .html
2 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/prettierrc",
3 | "singleQuote": true,
4 | "semi": true,
5 | "tabWidth": 2,
6 | "printWidth": 120
7 | }
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to v2 will be documented in this file.
4 |
5 | ## 2.1.0
6 |
7 | - Rewrite in TypeScript. Types are included with the package.
8 | - New prop `suppress-validation-warnings` to suppress warnings about invalid values.
9 | - Less pedantic validation of certain props like `size`.
10 | - `auto-adjust-text-size` now uses ResizeObserver instead of window resize event. This will allow for more accurate text size adjustments when the component is resized dynamically.
11 | - Source maps are now included with the package. *.min.js files are still included for backwards compatibility but they are just a copy of their *.js counterparts.
12 |
13 | ## 2.0.0
14 |
15 | ### Added
16 | - Support for Vue 3
17 |
18 | ## 1.x
19 |
20 | [Changelog for v1 (Vue 2)](https://github.com/dumptyd/vue-css-donut-chart/blob/legacy/CHANGELOG.md)
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018-2024 dumptyd
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 | | Using Vue 2? |
28 | | :- |
29 | | Check out the [documentation for vue-css-donut-chart v1](https://github.com/dumptyd/vue-css-donut-chart/tree/legacy). |
30 | | **NPM** - https://www.npmjs.com/package/vue-css-donut-chart/v/legacy |
31 |
32 | ## Preview
33 |
34 | Live demo – https://dumptyd.github.io/vue-css-donut-chart/
35 |
36 | Playground – https://jsfiddle.net/dumptyd/f4tmz0oy/
37 |
38 | ## Installation
39 |
40 | #### NPM
41 |
42 | ```console
43 | yarn add vue-css-donut-chart
44 | # OR
45 | npm i vue-css-donut-chart
46 | # OR
47 | pnpm add vue-css-donut-chart
48 | ```
49 |
50 | #### In-browser
51 |
52 | ```html
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
68 | ```
69 |
70 | ## Usage
71 |
72 | #### Basic usage
73 |
74 | ```vue
75 |
76 | Basic donut
77 |
78 |
85 | ```
86 |
87 | #### Usage with all available props and events
88 |
89 | ```vue
90 |
91 |
116 | 75%
117 |
118 |
119 |
130 | ```
131 |
132 | #### Using the component as a pie chart
133 |
134 | Set `thickness` to `0` to make the component look like a pie chart.
135 |
136 | ```vue
137 |
138 |
141 |
142 |
143 |
146 | ```
147 |
148 | **Note:** setting `thickness` to 100 will completely hide the chart's text or slot content. The content will still be present in the DOM, but it will not be visible.
149 |
150 | ## API
151 |
152 | #### Props
153 |
154 | | Prop | Type | Required | Default | Description |
155 | | ---- | :--: | :------: | :-----: | ----------- |
156 | | `size` | Number | No | `250` | Diameter of the donut. Can be any positive value. |
157 | | `unit` | String | No | `'px'` | Unit to use for `size`. Can be any valid CSS unit. Use `%` to make the donut responsive. |
158 | | `thickness` | Number | No | `20` | Percent thickness of the donut ring relative to `size`. Can be any positive value between 0-100 (inclusive). |
159 | | `text` | String | No | – | Text to show in the middle of the donut. This can also be provided through the default slot. |
160 | | `background` | String | No | `'#ffffff'` | Background color of the donut. In most cases, this should be the background color of the parent element. |
161 | | `foreground` | String | No | `'#eeeeee'` | Foreground color of the donut. This is the color that is shown for empty region of the donut ring. |
162 | | `start-angle` | Number | No | `0` | Angle measure in degrees where the first section should start. |
163 | | `total` | Number | No | `100` | Total for calculating the percentage for each section. |
164 | | `has-legend` | Boolean | No | `false` | Whether the donut should have a legend. |
165 | | `legend-placement` | String | No | `'bottom'` | Where the legend should be placed. Valid values are `top`, `right`, `bottom` and `left`. Doesn't have an effect if `has-legend` is not true. |
166 | | `auto-adjust-text-size` | Boolean | No | `true` | Whether the font-size of the donut content is calculated automatically to fit the available space proportionally. |
167 | | `sections` | Array | No | `[]` | An array of objects. Each object in the array represents a section. |
168 | | `section.value` | Number | **Yes** | – | Value of the section. The component determines what percent of the donut should be taken by a section based on this value and the `total` prop. Sum of all the sections' `value` should not exceed `total`. |
169 | | `section.color` | String | Read description | Read description | Color of the section. The component comes with 24 predefined colors, so this property is optional if you have <= 24 sections without the `color` property. |
170 | | `section.label` | String | No | `'Section '` | Name of the section. This is used in the legend as well as the tooltip text of the section. |
171 | | `section.ident` | String | No | – | Identifier for the section. This can be used to uniquely identify a section in the `section-*` events. |
172 | | `suppress-validation-warnings` | Boolean | No | `false` | Whether to suppress warnings for invalid prop values. |
173 |
174 |
175 | #### Events
176 |
177 | All the `section-*` listeners are called with the `section` object on which the event occurred and the native `Event` object as arguments respectively.
178 | Use the `ident` property to uniquely identify sections in the `section-*` events.
179 |
180 |
181 | | Event | Parameter | Description |
182 | | ---------- | ------------ | ----------- |
183 | | `section-click` | `section`, `event` | Emitted when a section is clicked. |
184 | | `section-mouseenter` | `section`, `event` | Emitted when the `mouseenter` event occurs on a section. |
185 | | `section-mouseleave` | `section`, `event` | Emitted when the `mouseleave` event occurs on a section. |
186 | | `section-mouseover` | `section`, `event` | Emitted when the `mouseover` event occurs on a section. |
187 | | `section-mouseout` | `section`, `event` | Emitted when the `mouseout` event occurs on a section. |
188 | | `section-mousemove` | `section`, `event` | Emitted when the `mousemove` event occurs on a section. |
189 |
190 | #### Slots
191 |
192 | | Slot | Description |
193 | | ---- | ----------- |
194 | | default slot | If you want more control over the content of the chart, default slot can be used instead of the `text` prop. |
195 | | `legend` | Slot for plugging in your own legend. |
196 |
197 | ## Typescript
198 |
199 | This package is written in TypeScript and comes with its own type definitions out of the box. The types are exported from the main package, so you can import them directly in your project.
200 |
201 | ```vue
202 |
221 | ```
222 |
223 | ### Types not working?
224 |
225 | The types for this component are generated using a fairly recent version of Vue (as of writing this, it's Vue 3.4.21). If they are not working for you, make sure to update your Vue 3 version to the latest.
226 |
227 | ## Contributing
228 |
229 | **Issues** – https://github.com/dumptyd/vue-css-donut-chart/issues
230 |
231 | ## License
232 |
233 | Code released under [MIT](https://github.com/vue-css-donut-chart/vue-css-donut-chart/blob/master/LICENSE) license.
234 |
--------------------------------------------------------------------------------
/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | vue-css-donut-chart - Lightweight Vue component for creating donut charts
13 |
14 |
15 |
16 |
17 | We're sorry but vue-css-donut-chart doesn't work properly without JavaScript enabled. Please enable it to continue.
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-css-donut-chart",
3 | "version": "2.1.0",
4 | "description": "Lightweight Vue component for creating donut charts",
5 | "author": "dumptyd ",
6 | "scripts": {
7 | "dev": "vite",
8 | "build:site": "npm run lint && npm run type-check && BUILD_TARGET=site vite build",
9 | "build:lib": "npm run lint && npm run type-check && BUILD_TARGET=lib vite build && cp dist/vcdonut.umd.js dist/vcdonut.umd.min.js",
10 | "preview": "vite preview",
11 | "test": "vitest --coverage",
12 | "type-check": "vue-tsc --build --force",
13 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
14 | "format": "prettier --write src/"
15 | },
16 | "main": "dist/vcdonut.umd.js",
17 | "module": "dist/vcdonut.mjs",
18 | "unpkg": "dist/vcdonut.umd.js",
19 | "types": "dist/vcdonut.d.ts",
20 | "peerDependencies": {
21 | "vue": "^3"
22 | },
23 | "devDependencies": {
24 | "@juggle/resize-observer": "^3.4.0",
25 | "@rushstack/eslint-patch": "^1.8.0",
26 | "@tsconfig/node20": "^20.1.4",
27 | "@types/jsdom": "^21.1.6",
28 | "@types/lodash-es": "^4.17.12",
29 | "@types/node": "^20.12.5",
30 | "@vitejs/plugin-vue": "^5.0.4",
31 | "@vitest/coverage-istanbul": "^2.0.5",
32 | "@vue/eslint-config-prettier": "^9.0.0",
33 | "@vue/eslint-config-typescript": "^13.0.0",
34 | "@vue/test-utils": "^2.4.5",
35 | "@vue/tsconfig": "^0.5.1",
36 | "eslint": "^8.57.0",
37 | "eslint-plugin-vue": "^9.23.0",
38 | "jsdom": "^24.0.0",
39 | "lodash-es": "^4.17.21",
40 | "npm-run-all2": "^6.1.2",
41 | "prettier": "^3.2.5",
42 | "typescript": "~5.4.0",
43 | "vite": "^5.2.8",
44 | "vite-plugin-dts": "^4.0.3",
45 | "vite-plugin-vue-devtools": "^7.0.25",
46 | "vitest": "^2.0.5",
47 | "vue": "^3.4.21",
48 | "vue-tsc": "^2.0.11"
49 | },
50 | "bugs": "https://github.com/dumptyd/vue-css-donut-chart/issues",
51 | "homepage": "https://dumptyd.github.io/vue-css-donut-chart/",
52 | "keywords": [
53 | "donut",
54 | "pie",
55 | "chart",
56 | "circle",
57 | "radial",
58 | "progress",
59 | "vue",
60 | "css"
61 | ],
62 | "license": "MIT",
63 | "repository": "https://github.com/dumptyd/vue-css-donut-chart"
64 | }
65 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dumptyd/vue-css-donut-chart/3427b08576de5ca78129fdf76c8e0feef04b0cad/public/favicon.png
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
17 |
--------------------------------------------------------------------------------
/src/components/DonutChart.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
15 |
16 |
17 |
18 | {{ text }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {{ legendItem.label }}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
254 |
--------------------------------------------------------------------------------
/src/components/DonutSections.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
16 |
153 |
--------------------------------------------------------------------------------
/src/components/site/ProductBadges.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
41 |
--------------------------------------------------------------------------------
/src/components/site/ProjectDemo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
17 |
18 | Documentation
19 | Installation
20 | Usage
21 | GitHub
22 |
23 |
24 |
30 |
31 |
32 |
33 |
34 |
35 |
Donut configuration
36 |
71 |
72 |
73 |
74 |
75 |
76 |
Legend configuration
77 |
78 |
79 | Has legend?
80 |
81 |
82 |
83 | Legend Placement
84 |
85 |
86 | {{ option }}
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
Donut content
97 |
98 |
99 | Content (HTML supported)
100 |
101 |
102 |
103 |
104 |
105 |
106 | Auto-adjust font size based on the size of the chart
107 |
108 |
109 | Try setting the size to 500px and then check and uncheck this setting to see the difference.
110 |
111 |
112 |
113 |
114 |
115 |
116 |
Section events
117 |
118 |
119 |
126 | section-{{ evt }}
127 |
128 |
129 |
130 |
Checked events will log to console when they're triggered.
131 |
132 |
133 |
134 |
135 |
136 |
Donut sections
137 |
138 |
139 |
{{ idx + 1 }}.
140 |
141 |
142 | Value
143 |
151 |
152 |
153 | Label
154 |
155 |
156 |
157 | Color
158 |
159 |
160 |
161 | - remove
162 |
163 |
164 |
165 |
166 |
167 | + add {{ editableSections.length ? 'another' : 'a' }} section
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
319 |
--------------------------------------------------------------------------------
/src/lib.ts:
--------------------------------------------------------------------------------
1 | import { type Plugin } from 'vue';
2 | import DonutChart, { type Props as DonutChartProps, type Emits as DonutChartEmits } from '@/components/DonutChart.vue';
3 | import type { DonutSection } from '@/utils/types';
4 |
5 | const DonutPlugin: Plugin = {
6 | install(app) {
7 | app.component('VcDonut', DonutChart);
8 | },
9 | };
10 |
11 | export type { DonutSection, DonutChartProps, DonutChartEmits };
12 | export { DonutChart as VcDonut };
13 | export default DonutPlugin;
14 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue';
2 | import App from './App.vue';
3 |
4 | createApp(App).mount('#app');
5 |
--------------------------------------------------------------------------------
/src/styles/main.css:
--------------------------------------------------------------------------------
1 | /*
2 | All classes are prefixed with `cdc-` (short for css-donut-chart)
3 | to avoid clashes with other classes in the app
4 | */
5 |
6 | /* Container for both the donut chart and the legend */
7 | .cdc-container {
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 | }
12 |
13 | /* Donut chart */
14 | .cdc {
15 | height: auto;
16 | border-radius: 50%;
17 | position: relative;
18 | overflow: hidden;
19 | flex-shrink: 0;
20 | }
21 |
22 | /* Middle of the donut */
23 | .cdc-overlay {
24 | opacity: 1;
25 | position: absolute;
26 | border-radius: 50%;
27 | display: flex;
28 | align-items: center;
29 | justify-content: center;
30 | overflow: hidden;
31 | }
32 |
33 | /* Donut center text */
34 | .cdc-text {
35 | text-align: center;
36 | }
37 |
38 | /* Legend container */
39 | .cdc-legend {
40 | display: flex;
41 | justify-content: center;
42 | margin-top: 1em;
43 | flex-wrap: wrap;
44 | }
45 |
46 | /* Legend item */
47 | .cdc-legend-item {
48 | display: inline-flex;
49 | align-items: center;
50 | margin: 0.5em;
51 | }
52 |
53 | /* Color square inside each legend item */
54 | .cdc-legend-item-color {
55 | height: 1.25em;
56 | width: 1.25em;
57 | border-radius: 2px;
58 | margin-right: 0.5em;
59 | }
60 |
61 | /* Container for all the sections of the donut */
62 | .cdc-sections {
63 | position: absolute;
64 | height: auto;
65 | width: 100%;
66 | padding-bottom: 100%;
67 | border-radius: 50%;
68 | }
69 |
70 | /* Section */
71 | .cdc-section {
72 | position: absolute;
73 | height: 100%;
74 | width: 50%;
75 | overflow: hidden;
76 | background-color: transparent;
77 | transform-origin: 0% 50%;
78 | pointer-events: none;
79 | }
80 |
81 | /* Part of the section to which the specified color is applied */
82 | .cdc-filler {
83 | position: absolute;
84 | height: 100%;
85 | width: 100%;
86 | pointer-events: all;
87 | }
88 |
89 | /* For sections that draw from 0 to 180 degrees */
90 | .cdc-section.cdc-section-right {
91 | left: 50%;
92 | }
93 | .cdc-section.cdc-section-right .cdc-filler {
94 | transform-origin: 0% 50%;
95 | }
96 |
97 | /* and 180 to 360 degrees */
98 | .cdc-section.cdc-section-left {
99 | left: 0%;
100 | transform-origin: 100% 50%;
101 | }
102 | .cdc-section.cdc-section-left .cdc-filler {
103 | transform-origin: 100% 50%;
104 | }
105 |
--------------------------------------------------------------------------------
/src/styles/normalize.css:
--------------------------------------------------------------------------------
1 | html {
2 | line-height: 1.15;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | }
8 |
9 | h1 {
10 | font-size: 2em;
11 | margin: 0.67em 0;
12 | }
13 |
14 | hr {
15 | box-sizing: content-box;
16 | height: 0;
17 | overflow: visible;
18 | }
19 |
20 | pre {
21 | font-family: monospace, monospace;
22 | font-size: 1em;
23 | }
24 |
25 | a {
26 | background-color: transparent;
27 | }
28 |
29 | abbr[title] {
30 | border-bottom: none;
31 | text-decoration: underline;
32 | text-decoration: underline dotted;
33 | }
34 |
35 | b,
36 | strong {
37 | font-weight: bolder;
38 | }
39 |
40 | code,
41 | kbd,
42 | samp {
43 | font-family: monospace, monospace;
44 | font-size: 1em;
45 | }
46 |
47 | small {
48 | font-size: 80%;
49 | }
50 |
51 | sub,
52 | sup {
53 | font-size: 75%;
54 | line-height: 0;
55 | position: relative;
56 | vertical-align: baseline;
57 | }
58 |
59 | sub {
60 | bottom: -0.25em;
61 | }
62 |
63 | sup {
64 | top: -0.5em;
65 | }
66 |
67 | img {
68 | border-style: none;
69 | }
70 |
71 | button,
72 | input,
73 | optgroup,
74 | select,
75 | textarea {
76 | font-family: inherit;
77 | font-size: 100%;
78 | line-height: 1.15;
79 | margin: 0;
80 | }
81 |
82 | button,
83 | input {
84 | overflow: visible;
85 | }
86 |
87 | button,
88 | select {
89 | text-transform: none;
90 | }
91 |
92 | button,
93 | [type='button'],
94 | [type='reset'],
95 | [type='submit'] {
96 | -webkit-appearance: button;
97 | }
98 |
99 | button::-moz-focus-inner,
100 | [type='button']::-moz-focus-inner,
101 | [type='reset']::-moz-focus-inner,
102 | [type='submit']::-moz-focus-inner {
103 | border-style: none;
104 | padding: 0;
105 | }
106 |
107 | button:-moz-focusring,
108 | [type='button']:-moz-focusring,
109 | [type='reset']:-moz-focusring,
110 | [type='submit']:-moz-focusring {
111 | outline: 1px dotted ButtonText;
112 | }
113 |
114 | fieldset {
115 | padding: 0.35em 0.75em 0.625em;
116 | }
117 |
118 | legend {
119 | box-sizing: border-box;
120 | color: inherit;
121 | display: table;
122 | max-width: 100%;
123 | padding: 0; /* 3 */
124 | white-space: normal;
125 | }
126 |
127 | progress {
128 | vertical-align: baseline;
129 | }
130 |
131 | textarea {
132 | overflow: auto;
133 | }
134 |
135 | [type='checkbox'],
136 | [type='radio'] {
137 | box-sizing: border-box;
138 | padding: 0;
139 | }
140 |
141 | [type='number']::-webkit-inner-spin-button,
142 | [type='number']::-webkit-outer-spin-button {
143 | height: auto;
144 | }
145 |
146 | [type='search'] {
147 | -webkit-appearance: textfield;
148 | outline-offset: -2px;
149 | }
150 |
151 | [type='search']::-webkit-search-decoration {
152 | -webkit-appearance: none;
153 | }
154 |
155 | ::-webkit-file-upload-button {
156 | -webkit-appearance: button;
157 | font: inherit;
158 | }
159 |
160 | details {
161 | display: block;
162 | }
163 |
164 | summary {
165 | display: list-item;
166 | }
167 |
168 | template {
169 | display: none;
170 | }
171 |
172 | [hidden] {
173 | display: none;
174 | }
175 |
--------------------------------------------------------------------------------
/src/styles/site.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css?family=Dosis:400,700');
2 |
3 | body {
4 | font-family: 'Dosis', 'Ubuntu', 'Arial', 'Helvetica', sans-serif;
5 | background-color: #f1f1f1;
6 | font-size: 18px;
7 | }
8 |
9 | a {
10 | color: #333;
11 | }
12 | a:hover {
13 | color: #000;
14 | }
15 |
16 | .container {
17 | margin: 3rem auto;
18 | max-width: 992px;
19 | box-shadow: 0 0 10px 0px #ddd;
20 | background-color: #fff;
21 | border-radius: 0.5rem;
22 | }
23 |
24 | .container-header {
25 | background-color: #333;
26 | color: #fff;
27 | text-align: center;
28 | font-size: 1.5rem;
29 | padding: 1.5rem 0.5rem;
30 | border-bottom: 1px solid #ddd;
31 | }
32 |
33 | .container-header h1 {
34 | margin: 0 0 0.5rem 0;
35 | }
36 |
37 | .container-nav {
38 | display: flex;
39 | justify-content: center;
40 | flex-wrap: wrap;
41 | padding: 1rem 0.5rem;
42 | border-bottom: 1px solid #ddd;
43 | }
44 | .container-nav > a {
45 | text-decoration: none;
46 | margin: 0.5rem 1rem;
47 | }
48 |
49 | .container-donut {
50 | background-color: #fff;
51 | min-height: 100px;
52 | overflow: auto;
53 | padding: 1.5rem 0;
54 | border-bottom: 1px solid #ddd;
55 | }
56 |
57 | .container-body {
58 | padding: 0 1.5rem 3rem;
59 | }
60 |
61 | .control-group {
62 | display: flex;
63 | flex-wrap: wrap;
64 | }
65 | .control {
66 | display: inline-flex;
67 | align-items: center;
68 | margin-bottom: 0.5rem;
69 | }
70 | .control-vertical {
71 | flex-direction: column;
72 | align-items: start;
73 | }
74 | .control:not(:last-child) {
75 | margin-right: 1.5rem;
76 | }
77 | .control-vertical label {
78 | margin-bottom: 1rem;
79 | }
80 | .control.checkbox {
81 | cursor: pointer;
82 | }
83 | .control.checkbox label {
84 | line-height: 1;
85 | cursor: pointer;
86 | }
87 | .control.checkbox input[type='checkbox']+label {
88 | margin-left: 0.5rem;
89 | }
90 | .control.checkbox input[type='checkbox'] {
91 | margin-top: 0.2rem;
92 | }
93 |
94 | footer {
95 | text-align: center;
96 | padding: 1rem;
97 | }
98 |
99 | .credit {
100 | margin-bottom: 0.5rem;
101 | }
102 |
103 | .badges {
104 | display: flex;
105 | align-items: center;
106 | justify-content: center;
107 | flex-wrap: wrap;
108 | }
109 |
110 | .badge {
111 | margin: 0.5rem 0.5rem 0;
112 | }
113 |
114 | .note {
115 | margin-top: 0.5rem;
116 | padding: 0.5rem 0.75rem;
117 | font-size: 0.9rem;
118 | border-left: 5px solid deepskyblue;
119 | }
120 |
121 | label {
122 | margin-right: 0.75rem;
123 | }
124 | input,
125 | select,
126 | textarea,
127 | button {
128 | background-color: inherit;
129 | border: 2px solid #d8d8d8;
130 | padding: 0.5rem;
131 | border-radius: 5px;
132 | display: inline-block;
133 | }
134 | input:invalid,
135 | select:invalid,
136 | textarea:invalid {
137 | box-shadow: 0 0 10px 2px tomato;
138 | z-index: 1;
139 | }
140 | input[type='color'] {
141 | height: 2.5rem;
142 | padding: 0;
143 | border: none;
144 | }
145 | input[type='checkbox'] {
146 | cursor: pointer;
147 | }
148 |
149 | input.sm,
150 | select.sm {
151 | max-width: 5rem;
152 | }
153 |
154 | button {
155 | display: inline-flex;
156 | align-items: center;
157 | padding: 0.75rem 1.5rem;
158 | cursor: pointer;
159 | transition: 0.1s all ease-in;
160 | }
161 | button.outline-green {
162 | border-color: #33995f;
163 | color: #33995f;
164 | }
165 | button.outline-green:hover {
166 | background-color: #33995f;
167 | color: #fff;
168 | }
169 | button.outline-red {
170 | border-color: #ff6666;
171 | color: #ff6666;
172 | }
173 | button.outline-red:hover {
174 | background-color: #ff6666;
175 | color: #fff;
176 | }
177 |
178 | .flex-grow-1 {
179 | flex-grow: 1;
180 | }
181 | .flex-start {
182 | align-items: stretch;
183 | }
184 | .flex-column {
185 | flex-direction: column;
186 | }
187 | .w-100 {
188 | width: 100%;
189 | }
190 | .big {
191 | font-size: 1.5em;
192 | }
193 |
194 | @media (max-width: 768px) {
195 | .container {
196 | margin: 0 auto;
197 | }
198 | .container-header {
199 | font-size: 1.125rem;
200 | }
201 | }
202 | @media (max-width: 850px) {
203 | .donut-sections .control-group {
204 | flex-direction: column;
205 | }
206 | }
207 | @media (min-width: 768px) and (min-height: 500px) {
208 | .container-donut {
209 | position: sticky;
210 | top: 0;
211 | max-height: 300px;
212 | }
213 | }
214 |
215 | .vue-version-select {
216 | font-weight: bold;
217 | color: #42b883;
218 | font-size: 0.85em;
219 | padding: 0.1em 0.2em;
220 | margin: 0 0.2em;
221 | border-width: 1px;
222 | }
223 |
--------------------------------------------------------------------------------
/src/utils/colors.ts:
--------------------------------------------------------------------------------
1 | export const defaultColors: readonly string[] = [
2 | '#FF6384',
3 | '#36A2EB',
4 | '#FFCE56',
5 | '#F58231',
6 | '#46F0F0',
7 | '#D2F53C',
8 | '#911EB4',
9 | '#F032E6',
10 | '#3CB44B',
11 | '#FFE119',
12 | '#E6194B',
13 | '#FABEBE',
14 | '#008080',
15 | '#E6BEFF',
16 | '#0082C8',
17 | '#AA6E28',
18 | '#FFFAC8',
19 | '#800000',
20 | '#AAFFC3',
21 | '#808000',
22 | '#FFD8B1',
23 | '#000080',
24 | '#808080',
25 | '#000000',
26 | ];
27 |
28 | /** returns a dispatcher that returns the next default color with every call */
29 | export const getDefaultColorDispatcher = () => {
30 | let currentDefaultColorIdx = 0;
31 | let hasWrapped = false;
32 | const dispatcher = () => {
33 | if (currentDefaultColorIdx === 0 && hasWrapped) {
34 | console.warn('Ran out of default colors, reusing colors');
35 | }
36 |
37 | const color = defaultColors[currentDefaultColorIdx];
38 |
39 | currentDefaultColorIdx = (currentDefaultColorIdx + 1) % defaultColors.length;
40 | if (currentDefaultColorIdx === 0) hasWrapped = true;
41 |
42 | return color;
43 | };
44 |
45 | return dispatcher;
46 | };
47 |
--------------------------------------------------------------------------------
/src/utils/misc.ts:
--------------------------------------------------------------------------------
1 | import { getCurrentInstance, onUnmounted, ref, watch, type Ref, type StyleValue } from 'vue';
2 |
3 | const legendGap = '1em';
4 |
5 | export const legendPlacementStyles: Record<
6 | 'top' | 'right' | 'bottom' | 'left',
7 | {
8 | container: StyleValue;
9 | legend: StyleValue;
10 | }
11 | > = {
12 | top: {
13 | container: { flexDirection: 'column' },
14 | legend: { order: -1, margin: 0, marginBottom: legendGap },
15 | },
16 | right: {
17 | container: {},
18 | legend: {
19 | flexDirection: 'column',
20 | margin: 0,
21 | marginLeft: legendGap,
22 | },
23 | },
24 | bottom: {
25 | container: { flexDirection: 'column' },
26 | legend: {},
27 | },
28 | left: {
29 | container: {},
30 | legend: {
31 | flexDirection: 'column',
32 | order: -1,
33 | margin: 0,
34 | marginRight: legendGap,
35 | },
36 | },
37 | };
38 |
39 | export function toPrecise(value: number): number {
40 | return Number(value.toPrecision(15));
41 | }
42 |
43 | export function useElementSize({
44 | el,
45 | onResize,
46 | }: {
47 | el: Ref;
48 | onResize?: (width: number, height: number) => void;
49 | }) {
50 | const width = ref(0);
51 | const height = ref(0);
52 |
53 | const observer = new ResizeObserver((entries) => {
54 | if (!el.value) return;
55 | const entry = entries[0];
56 | width.value = entry.contentRect.width;
57 | height.value = entry.contentRect.height;
58 | /* istanbul ignore else -- @preserve */
59 | if (onResize) onResize(width.value, height.value);
60 | });
61 |
62 | watch(
63 | el,
64 | (newEl, oldEl) => {
65 | if (oldEl) observer.unobserve(oldEl);
66 | if (newEl) observer.observe(newEl);
67 | },
68 | { immediate: true },
69 | );
70 |
71 | const stop = () => {
72 | observer.disconnect();
73 | };
74 |
75 | if (getCurrentInstance()) {
76 | onUnmounted(() => {
77 | stop();
78 | });
79 | }
80 |
81 | return { width, height, stop };
82 | }
83 |
--------------------------------------------------------------------------------
/src/utils/types.ts:
--------------------------------------------------------------------------------
1 | export interface DonutSection {
2 | /** The value of the section. Actual size is determined based on this value relative to the `total`. */
3 | value: number;
4 | /** The label to display in the legend. */
5 | label?: string;
6 | /** The color of the section. */
7 | color?: string;
8 | /** Identifier for the section. Useful for identifying sections in events. */
9 | ident?: unknown;
10 | }
11 |
--------------------------------------------------------------------------------
/tests/unit/donut.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest';
2 | import { mount } from '@vue/test-utils';
3 | import { cloneDeep } from 'lodash-es';
4 | import { El, hexToCssRgb, resizeObserverMock, timeout } from '../utils';
5 | import DonutChart from '@/components/DonutChart.vue';
6 | import { defaultColors } from '@/utils/colors';
7 | import type { DonutSection } from '@/utils/types';
8 | import DonutPlugin, { VcDonut } from '@/lib';
9 | import { createApp } from 'vue';
10 |
11 | describe('Donut component', () => {
12 | beforeAll(() => {
13 | global.ResizeObserver = resizeObserverMock.mock;
14 | });
15 |
16 | describe('"size" prop', () => {
17 | it("renders the donut with 250px height and width when the size isn't specified", () => {
18 | const wrapper = mount(DonutChart);
19 | const donutStyles = wrapper.find(El.DONUT).element.style;
20 |
21 | expect(donutStyles.width).toBe('250px');
22 | expect(donutStyles.paddingBottom).toBe('250px');
23 | });
24 |
25 | it('renders the donut with the correct height and width based on the size prop', () => {
26 | const size = 200;
27 | const sizeWithUnit = `${size}px`;
28 |
29 | const wrapper = mount(DonutChart, { props: { size } });
30 | const donutStyles = wrapper.find(El.DONUT).element.style;
31 |
32 | expect(donutStyles.width).toBe(sizeWithUnit);
33 | expect(donutStyles.paddingBottom).toBe(sizeWithUnit);
34 | });
35 | });
36 |
37 | describe('"unit" prop', () => {
38 | it("defaults to px unit for the donut size when the unit isn't specified", () => {
39 | const defaultUnit = 'px';
40 |
41 | const wrapper = mount(DonutChart);
42 | const donutStyles = wrapper.find(El.DONUT).element.style;
43 |
44 | expect(donutStyles.width.endsWith(defaultUnit)).toBe(true);
45 | expect(donutStyles.paddingBottom.endsWith(defaultUnit)).toBe(true);
46 | });
47 |
48 | it('respects the unit provided via unit prop for donut size', () => {
49 | const [size, unit] = [50, '%'];
50 |
51 | const wrapper = mount(DonutChart, { props: { size, unit } });
52 | const donutStyles = wrapper.find(El.DONUT).element.style;
53 |
54 | expect(donutStyles.width.endsWith(unit)).toBe(true);
55 | expect(donutStyles.paddingBottom.endsWith(unit)).toBe(true);
56 | });
57 | });
58 |
59 | describe('"thickness" prop', () => {
60 | it("renders the donut with 20% ring thickness when the thickness isn't specified", () => {
61 | const defaultThickness = 20;
62 |
63 | const wrapper = mount(DonutChart);
64 | const overlayStyles = wrapper.find(El.DONUT_OVERLAY).element.style;
65 |
66 | const expectedDonutOverlaySize = `${100 - defaultThickness}%`;
67 | expect(overlayStyles.width).toBe(expectedDonutOverlaySize);
68 | expect(overlayStyles.height).toBe(expectedDonutOverlaySize);
69 | });
70 |
71 | it('renders the donut with correct ring thickness based on the thickness prop', () => {
72 | const thickness = 30;
73 |
74 | const wrapper = mount(DonutChart, { props: { thickness } });
75 | const overlayStyles = wrapper.find(El.DONUT_OVERLAY).element.style;
76 |
77 | const expectedDonutOverlaySize = `${100 - thickness}%`;
78 | expect(overlayStyles.width).toBe(expectedDonutOverlaySize);
79 | expect(overlayStyles.height).toBe(expectedDonutOverlaySize);
80 | });
81 | });
82 |
83 | describe('"text" prop and default slot', () => {
84 | it('renders the text provided via text prop in the center of the donut', () => {
85 | const text = 'An example text.';
86 |
87 | const wrapper = mount(DonutChart, { props: { text } });
88 | const overlay = wrapper.find(El.DONUT_OVERLAY);
89 |
90 | expect(overlay.text()).toBe(text);
91 | });
92 |
93 | it('renders the content provided via default slot in the center of the donut', () => {
94 | const html = 'An example text.
';
95 |
96 | const wrapper = mount(DonutChart, {
97 | slots: { default: html },
98 | });
99 | const overlayEl = wrapper.find(El.DONUT_OVERLAY_CONTENT).element;
100 |
101 | expect(overlayEl.innerHTML).toBe(html);
102 | });
103 | });
104 |
105 | describe('"background" and "foreground" props', () => {
106 | it("renders the donut with default background and foreground colors when they're not specified", () => {
107 | const [defaultForegroundColor, defaultBackgroundColor] = ['#eeeeee', '#ffffff'];
108 |
109 | const wrapper = mount(DonutChart);
110 | const donut = wrapper.find(El.DONUT).element;
111 | const donutOverlay = wrapper.find(El.DONUT_OVERLAY).element;
112 |
113 | expect(donut.style.backgroundColor).toBe(hexToCssRgb(defaultForegroundColor));
114 | expect(donutOverlay.style.backgroundColor).toBe(hexToCssRgb(defaultBackgroundColor));
115 | });
116 |
117 | it('renders the donut with specified background and foreground colors', () => {
118 | const [foreground, background] = ['#abcdef', '#fedcba'];
119 |
120 | const wrapper = mount(DonutChart, { props: { foreground, background } });
121 | const donut = wrapper.find(El.DONUT).element;
122 | const donutOverlay = wrapper.find(El.DONUT_OVERLAY).element;
123 |
124 | expect(donut.style.backgroundColor).toBe(hexToCssRgb(foreground));
125 | expect(donutOverlay.style.backgroundColor).toBe(hexToCssRgb(background));
126 | });
127 | });
128 |
129 | describe('"sections" prop', () => {
130 | it('renders correct number of sections based on the sections prop', async () => {
131 | let sections = [{ value: 25 }, { value: 25 }, { value: 25 }, { value: 25 }];
132 |
133 | const wrapper = mount(DonutChart, { props: { sections } });
134 |
135 | let sectionWrappers = wrapper.findAll(El.DONUT_SECTION);
136 | expect(sectionWrappers).toHaveLength(sections.length);
137 |
138 | sections = [{ value: 20 }, { value: 20 }];
139 | await wrapper.setProps({ sections });
140 |
141 | sectionWrappers = wrapper.findAll(El.DONUT_SECTION);
142 | expect(sectionWrappers).toHaveLength(sections.length);
143 |
144 | sections = [{ value: 60 }, { value: 20 }];
145 | await wrapper.setProps({ sections });
146 |
147 | sectionWrappers = wrapper.findAll(El.DONUT_SECTION);
148 | // since one section takes up more than 180 degrees, it should be split into 2
149 | expect(sectionWrappers).toHaveLength(sections.length + 1);
150 | });
151 |
152 | it('renders correct number of sections while accounting for floating-point arithmetic issues', () => {
153 | // when using these values for sections and size, the component used to render 6 sections
154 | // because of floating point issues. That should never happen - the component should at
155 | // maximum have `sections.length + 1` sections.
156 | const [fpaValues, fpaSize] = [[33, 33, 33, 1], 201];
157 | const sections = fpaValues.map((value) => ({ value }));
158 |
159 | const wrapper = mount(DonutChart, { props: { sections, size: fpaSize } });
160 | const sectionWrappers = wrapper.findAll(El.DONUT_SECTION);
161 |
162 | // it should have 5 sections since the second 33 would get split into two sections
163 | expect(sectionWrappers).toHaveLength(5);
164 | });
165 |
166 | it('renders the sections with correct colors and plugs in default colors if one isn\'t specified with the "color" property', () => {
167 | const sectionWithExplicitColor = { value: 25, color: '#abcdef' };
168 | const sections = [{ value: 25 }, sectionWithExplicitColor, { value: 25 }];
169 |
170 | const wrapper = mount(DonutChart, { props: { sections } });
171 | const sectionFillerWrappers = wrapper.findAll(El.DONUT_SECTION_FILLER);
172 | expect(sectionFillerWrappers[0].element.style.backgroundColor).toBe(hexToCssRgb(defaultColors[0]));
173 | expect(sectionFillerWrappers[1].element.style.backgroundColor).toBe(hexToCssRgb(sectionWithExplicitColor.color));
174 | expect(sectionFillerWrappers[2].element.style.backgroundColor).toBe(hexToCssRgb(defaultColors[1]));
175 | });
176 |
177 | it('sets the correct title attribute for each section based on the "label" property', () => {
178 | const sections = [
179 | { label: 'Section 1 label', value: 25 },
180 | { label: 'Section 2 label', value: 25 },
181 | { value: 25 },
182 | ];
183 |
184 | const wrapper = mount(DonutChart, { props: { sections } });
185 | const sectionFillerWrappers = wrapper.findAll(El.DONUT_SECTION_FILLER);
186 |
187 | expect(sectionFillerWrappers[0].attributes('title')).toBe(sections[0].label);
188 | expect(sectionFillerWrappers[1].attributes('title')).toBe(sections[1].label);
189 | expect(sectionFillerWrappers[2].attributes('title')).toBeFalsy(); // no default title
190 | });
191 |
192 | it('does not run into error when section.value is not of number type', () => {
193 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
194 | const sections = [{ value: 10 }, { value: '' }];
195 |
196 | let error: unknown = false;
197 | try {
198 | mount(DonutChart, {
199 | props: {
200 | // @ts-expect-error we're testing invalid values here
201 | sections,
202 | },
203 | });
204 | } catch (err) {
205 | error = err;
206 | } finally {
207 | expect(error).toBeFalsy();
208 | spy.mockRestore();
209 | if (error) console.error(error);
210 | }
211 | });
212 | });
213 |
214 | describe('"total" prop', () => {
215 | it('renders the sections differently based on the "total" prop', async () => {
216 | const sections = [{ value: 90 }];
217 | const wrapper = mount(DonutChart, { props: { sections } });
218 |
219 | let sectionWrappers = wrapper.findAll(El.DONUT_SECTION);
220 | // section is split into 2 elements since it's taking more than half the donut area
221 | expect(sectionWrappers).toHaveLength(sections.length + 1);
222 |
223 | await wrapper.setProps({ total: 200 });
224 |
225 | sectionWrappers = wrapper.findAll(El.DONUT_SECTION);
226 | // section is not split into 2 elements anymore because it's not taking more than half the total value
227 | expect(sectionWrappers).toHaveLength(sections.length);
228 | });
229 |
230 | it('throws an error if sum of the section values exceed total', () => {
231 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
232 |
233 | const [total, sections] = [50, [{ value: 25 }, { value: 26 }]];
234 |
235 | mount(DonutChart, { props: { total, sections } });
236 | expect(spy).toHaveBeenCalled();
237 | // read the last console.warn call
238 | const lastCall = spy.mock.calls[spy.mock.calls.length - 1];
239 | expect(lastCall[0]).toContain('exceeds `total`');
240 | spy.mockRestore();
241 | });
242 |
243 | it('accounts for floating-point arithmetic issues before throwing an error', () => {
244 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
245 | // when using these values in this order, validation logic used to throw an error
246 | const fpaValues = [8.2, 34.97, 30.6, 26.23];
247 | const [total, sections] = [100, fpaValues.map((value) => ({ value }))];
248 |
249 | mount(DonutChart, { props: { total, sections } });
250 | expect(spy).not.toHaveBeenCalled();
251 | spy.mockRestore();
252 | });
253 |
254 | it('tolerates 0 value', async () => {
255 | // ensure no error is thrown or warning is logged
256 | const warnSpy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
257 | const errorSpy = vi.spyOn(global.console, 'error').mockImplementation(() => {});
258 |
259 | const wrapper = mount(DonutChart, {
260 | props: {
261 | sections: [{ value: 0 }],
262 | total: 0,
263 | hasLegend: true,
264 | },
265 | });
266 | await timeout(50);
267 |
268 | await wrapper.vm.$nextTick();
269 |
270 | expect(warnSpy).not.toHaveBeenCalled();
271 | expect(errorSpy).not.toHaveBeenCalled();
272 |
273 | warnSpy.mockRestore();
274 | errorSpy.mockRestore();
275 | });
276 | });
277 |
278 | describe('"has-legend" prop', () => {
279 | it('renders the legend with proper legend items', () => {
280 | const sections = [
281 | { label: 'Section 1 with value 10', value: 10, color: '#aaaaaa' },
282 | { label: 'Section 2 with value 20', value: 20, color: '#bbbbbb' },
283 | { label: 'Section 3 with value 30', value: 30, color: '#cccccc' },
284 | ];
285 | const wrapper = mount(DonutChart, { props: { sections, hasLegend: true } });
286 |
287 | const legendItems = wrapper.findAll(El.LEGEND_ITEM);
288 | const legendItemColors = wrapper.findAll(El.LEGEND_ITEM_COLOR);
289 |
290 | sections.forEach((section, idx) => {
291 | expect(legendItems[idx].text()).toContain(section.label);
292 | expect(legendItemColors[idx].element.style.backgroundColor).toContain(hexToCssRgb(section.color));
293 | });
294 | });
295 |
296 | it("renders the legend with default plugged in colors and text if they're not provided", () => {
297 | const sections = [10, 20, 30].map((value) => ({ value }));
298 | const wrapper = mount(DonutChart, { props: { sections, hasLegend: true } });
299 |
300 | const legendItems = wrapper.findAll(El.LEGEND_ITEM);
301 | const legendItemColors = wrapper.findAll(El.LEGEND_ITEM_COLOR);
302 |
303 | sections.forEach((_, idx) => {
304 | expect(legendItems[idx].text()).toContain(`Section ${idx + 1}`);
305 | expect(legendItemColors[idx].element.style.backgroundColor).toContain(hexToCssRgb(defaultColors[idx]));
306 | });
307 | });
308 |
309 | it("doesn't render the legend by default", () => {
310 | const sections = [{ value: 10 }];
311 | const wrapper = mount(DonutChart, { props: { sections } });
312 |
313 | const legend = wrapper.find(El.LEGEND);
314 | const legendItems = wrapper.findAll(El.LEGEND_ITEM);
315 |
316 | expect(legend.exists()).toBe(false);
317 | expect(legendItems).toHaveLength(0);
318 | });
319 | });
320 |
321 | describe('"legend-placement" prop', () => {
322 | it('renders the legend on the correct side based on the "legend-placement" prop', async () => {
323 | const sections = [10, 20, 30].map((value) => ({ value }));
324 | const wrapper = mount(DonutChart, { props: { sections, hasLegend: true } });
325 |
326 | const placements = ['top', 'right', 'bottom', 'left'] as const;
327 |
328 | for (let idx = 0; idx < placements.length; ++idx) {
329 | const placement = placements[idx];
330 | await wrapper.setProps({ legendPlacement: placement });
331 | const containerEl = wrapper.find(El.MAIN_CONTAINER);
332 | const legendEl = wrapper.find(El.LEGEND);
333 |
334 | if (placement === 'top' || placement === 'bottom') {
335 | expect(containerEl.element.style.flexDirection).toBe('column'); // chart layout is vertical
336 | expect(legendEl.element.style.flexDirection).not.toBe('column'); // legend flows horizontally
337 | }
338 | if (placement === 'right' || placement === 'left') {
339 | expect(containerEl.element.style.flexDirection).not.toBe('column'); // chart layout is horizontal
340 | expect(legendEl.element.style.flexDirection).toBe('column'); // legend flows vertically
341 | }
342 |
343 | if (placement === 'top') {
344 | expect(legendEl.element.style.order).toBe('-1'); // legend before chart
345 | } else if (placement === 'right') {
346 | expect(legendEl.element.style.order).not.toBe('-1'); // legend after chart
347 | } else if (placement === 'bottom') {
348 | expect(legendEl.element.style.order).not.toBe('-1'); // legend after chart
349 | } else if (placement === 'left') {
350 | expect(legendEl.element.style.order).toBe('-1'); // legend before chart
351 | }
352 |
353 | // extract commons
354 | }
355 | });
356 | });
357 |
358 | describe('"legend" slot', () => {
359 | it('renders the provided content in "legend" slot instead of the default legend', () => {
360 | const sections = [10, 20, 30].map((value) => ({ value }));
361 | const legendHtml = 'Custom legend.
';
362 | const wrapper = mount(DonutChart, {
363 | props: { sections, hasLegend: true },
364 | slots: { legend: legendHtml },
365 | });
366 |
367 | expect(wrapper.find(El.LEGEND).exists()).toBe(false);
368 | expect(wrapper.html()).toContain(legendHtml);
369 | });
370 | });
371 |
372 | describe('"start-angle" prop', () => {
373 | it('renders the sections with correct "start-angle" offset', () => {
374 | const sections = [10, 20, 30].map((value) => ({ value }));
375 | const startAngle = 45;
376 |
377 | const wrapper = mount(DonutChart, { props: { sections, startAngle } });
378 | const sectionsContainerStyles = wrapper.find(El.DONUT_SECTIONS_CONTAINER).element.style;
379 |
380 | expect(sectionsContainerStyles.transform).toBe(`rotate(${startAngle}deg)`);
381 | });
382 | });
383 |
384 | describe('section events', () => {
385 | const supportedNativeSectionEvents = [
386 | 'click',
387 | 'mouseenter',
388 | 'mouseleave',
389 | 'mouseover',
390 | 'mouseout',
391 | 'mousemove',
392 | ].map((evt) => ({
393 | nativeEventName: evt,
394 | sectionEventName: `section-${evt}`,
395 | }));
396 | const RenderedDonut = (sections: DonutSection[]) => ({
397 | template: ' ',
398 | components: {
399 | 'vc-donut': DonutChart,
400 | },
401 | data() {
402 | return {
403 | sections: cloneDeep(sections),
404 | };
405 | },
406 | });
407 | supportedNativeSectionEvents.forEach(({ nativeEventName, sectionEventName }) => {
408 | it(`emits the "${sectionEventName}" event with correct payload when native "${nativeEventName}" occurs`, async () => {
409 | const sections = [
410 | { name: 'section-1', value: 10 },
411 | { name: 'section-2', value: 10 },
412 | { name: 'section-3', value: 10 },
413 | ];
414 | const sectionsCopy = cloneDeep(sections);
415 |
416 | const wrapper = mount(RenderedDonut(sections));
417 | const sectionWrappers = wrapper.findAll(El.DONUT_SECTION);
418 | const donutWrapper = wrapper.findComponent(DonutChart);
419 |
420 | for (let idx = 0; idx < sections.length; ++idx) {
421 | // trigger the native event on section
422 | await sectionWrappers[idx].trigger(nativeEventName);
423 | const sectionEvents = donutWrapper.emitted(sectionEventName);
424 | const eventData = sectionEvents?.[idx] as [DonutSection, Event];
425 |
426 | // assert that correct number of events have been emitted
427 | expect(sectionEvents).toHaveLength(idx + 1);
428 | // assert that the object passed by the user is the one that's returned back and not the internal one
429 | expect(eventData[0]).toBe(wrapper.vm.sections[idx]);
430 | // and the object hasn't been mutated
431 | expect(eventData[0]).toStrictEqual(sectionsCopy[idx]);
432 | // and the second argument is the native event
433 | expect(eventData[1]).toBeInstanceOf(Event);
434 | }
435 | });
436 | });
437 |
438 | // sections with value: 0 are rendered in the DOM with width: 0, however native events like
439 | // mouseover/mouseenter/mouseleave still sometimes occur on these sections but we want to make
440 | // sure these don't emit corresponding section-* events.
441 | it('does not emit section events when the native events occur on a section with value set to 0', () => {
442 | const zeroSection = { name: 'section-1', value: 0 };
443 | const sections = [zeroSection];
444 | const wrapper = mount(RenderedDonut(sections));
445 | const sectionWrapper = wrapper.find(El.DONUT_SECTION);
446 | // make sure value:0 sections are not rendered
447 | expect(sectionWrapper.exists()).toBeFalsy();
448 | });
449 | });
450 |
451 | describe('"auto-adjust-text-size" prop - font-size recalculation for chart content', () => {
452 | afterEach(() => {
453 | vi.restoreAllMocks();
454 | });
455 |
456 | it('triggers font-size recalculation when the component is mounted', async () => {
457 | const wrapper = mount(DonutChart, {
458 | props: {
459 | size: 50,
460 | },
461 | });
462 | const textEl = wrapper.find(El.DONUT_OVERLAY_CONTENT).element;
463 |
464 | expect(textEl.style.fontSize).toBe('1em'); // start with the default font size
465 |
466 | await resizeObserverMock.trigger();
467 |
468 | expect(textEl.style.fontSize).toBeTruthy();
469 | expect(textEl.style.fontSize).not.toBe('1em'); // font size should have changed
470 | });
471 |
472 | it('triggers font-size recalculation when `size` or `unit` props are updated', async () => {
473 | const wrapper = mount(DonutChart, {
474 | props: {
475 | size: 50,
476 | },
477 | });
478 | const textEl = wrapper.find(El.DONUT_OVERLAY_CONTENT);
479 |
480 | await resizeObserverMock.trigger();
481 | const oldFontSize = textEl.element.style.fontSize;
482 | expect(oldFontSize).not.toBe('1em'); // font size should have changed from default
483 |
484 | await wrapper.setProps({ size: 100 });
485 | await resizeObserverMock.trigger();
486 |
487 | const newFontSize = textEl.element.style.fontSize;
488 | expect(newFontSize).not.toBe('1em'); // font size should not have reset
489 | expect(newFontSize).not.toBe(oldFontSize); // font size should have changed
490 |
491 | await wrapper.setProps({ unit: '%' });
492 | await resizeObserverMock.trigger();
493 |
494 | const fontSizeAfterUnitChange = textEl.element.style.fontSize;
495 | expect(fontSizeAfterUnitChange).not.toBe(newFontSize); // font size should have changed
496 | });
497 |
498 | it('removes the resize listener from window when the component is destroyed', () => {
499 | const wrapper = mount(DonutChart);
500 | wrapper.unmount();
501 | expect(resizeObserverMock.mockFns.disconnect).toHaveBeenCalled();
502 | });
503 |
504 | it('does not perform recalculation or set resize listener when "auto-adjust-text-size" is not set', async () => {
505 | const wrapper = mount(DonutChart, {
506 | props: {
507 | size: 50,
508 | autoAdjustTextSize: false,
509 | },
510 | });
511 |
512 | const textEl = wrapper.find(El.DONUT_OVERLAY_CONTENT).element;
513 | expect(textEl.style.fontSize).toBe('1em'); // start with the default font size
514 |
515 | await resizeObserverMock.trigger();
516 |
517 | expect(textEl.style.fontSize).toBe('1em'); // font size should not have changed
518 | });
519 |
520 | it('does not perform recalculation when "auto-adjust-text-size" goes from true to false', async () => {
521 | const wrapper = mount(DonutChart, {
522 | props: {
523 | size: 50,
524 | },
525 | });
526 | await resizeObserverMock.trigger();
527 |
528 | const textEl = wrapper.find(El.DONUT_OVERLAY_CONTENT).element;
529 | expect(textEl.style.fontSize).not.toBe('1em'); // font size should have changed
530 |
531 | await wrapper.setProps({ autoAdjustTextSize: false });
532 |
533 | expect(textEl.style.fontSize).toBe('1em'); // font size should have reset
534 | });
535 |
536 | it('performs recalculation when "auto-adjust-text-size" goes from false to true', async () => {
537 | const wrapper = mount(DonutChart, {
538 | props: {
539 | size: 50,
540 | autoAdjustTextSize: false,
541 | },
542 | });
543 | await resizeObserverMock.trigger();
544 |
545 | const textEl = wrapper.find(El.DONUT_OVERLAY_CONTENT).element;
546 | expect(textEl.style.fontSize).toBe('1em'); // start with the default font size
547 |
548 | await wrapper.setProps({ autoAdjustTextSize: true });
549 | await resizeObserverMock.trigger();
550 |
551 | expect(textEl.style.fontSize).not.toBe('1em'); // font size should have changed
552 | });
553 | });
554 |
555 | describe('validation and "suppress-validation-warnings" prop', () => {
556 | afterEach(() => {
557 | vi.restoreAllMocks();
558 | });
559 |
560 | describe('"size" prop', () => {
561 | it('yells when "size" is negative', async () => {
562 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
563 | const wrapper = mount(DonutChart, {
564 | props: {
565 | size: -50,
566 | },
567 | });
568 | await wrapper.vm.$nextTick();
569 | expect(spy).toHaveBeenCalled();
570 | // get the last console.warn call
571 | const lastCall = spy.mock.calls[spy.mock.calls.length - 1];
572 | expect(lastCall[0]).toContain('`size` must be a positive number');
573 | spy.mockRestore();
574 | });
575 |
576 | it('respects "suppress-validation-warnings"', async () => {
577 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
578 | const wrapper = mount(DonutChart, {
579 | props: {
580 | size: -50,
581 | suppressValidationWarnings: true,
582 | },
583 | });
584 | await wrapper.vm.$nextTick();
585 | expect(spy).not.toHaveBeenCalled();
586 | spy.mockRestore();
587 | });
588 | });
589 |
590 | describe('"thickness" prop', () => {
591 | it('yells when "thickness" is out of range', async () => {
592 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
593 | const wrapper = mount(DonutChart, {
594 | props: {
595 | thickness: 150,
596 | },
597 | });
598 | await wrapper.vm.$nextTick();
599 | expect(spy).toHaveBeenCalled();
600 | // get the last console.warn call
601 | const lastCall = spy.mock.calls[spy.mock.calls.length - 1];
602 | expect(lastCall[0]).toContain('`thickness` must be between 0 and 100');
603 | spy.mockRestore();
604 | });
605 |
606 | it('respects "suppress-validation-warnings"', async () => {
607 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
608 | const wrapper = mount(DonutChart, {
609 | props: {
610 | thickness: 150,
611 | suppressValidationWarnings: true,
612 | },
613 | });
614 | await wrapper.vm.$nextTick();
615 | expect(spy).not.toHaveBeenCalled();
616 | spy.mockRestore();
617 | });
618 | });
619 |
620 | describe('"total" prop', () => {
621 | it('yells when "total" is negative', async () => {
622 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
623 | const wrapper = mount(DonutChart, {
624 | props: {
625 | total: -50,
626 | },
627 | });
628 | await wrapper.vm.$nextTick();
629 | expect(spy).toHaveBeenCalled();
630 | // get the last console.warn call
631 | const lastCall = spy.mock.calls[spy.mock.calls.length - 1];
632 | expect(lastCall[0]).toContain('`total` must be a positive number');
633 | spy.mockRestore();
634 | });
635 |
636 | it('yells when section values exceed total', async () => {
637 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
638 | const wrapper = mount(DonutChart, {
639 | props: {
640 | total: 50,
641 | sections: [{ value: 25 }, { value: 25.01 }],
642 | },
643 | });
644 | await wrapper.vm.$nextTick();
645 | expect(spy).toHaveBeenCalled();
646 | // get the last console.warn call
647 | const lastCall = spy.mock.calls[spy.mock.calls.length - 1];
648 | expect(lastCall[0]).toContain('exceeds `total`');
649 | spy.mockRestore();
650 | });
651 |
652 | it('respects "suppress-validation-warnings"', async () => {
653 | const spy = vi.spyOn(global.console, 'warn').mockImplementation(() => {});
654 | const wrapper = mount(DonutChart, {
655 | props: {
656 | total: -50,
657 | suppressValidationWarnings: true,
658 | },
659 | });
660 | await wrapper.vm.$nextTick();
661 | expect(spy).not.toHaveBeenCalled();
662 |
663 | await wrapper.setProps({
664 | total: 50,
665 | sections: [{ value: 25 }, { value: 25.01 }],
666 | suppressValidationWarnings: true,
667 | });
668 |
669 | await wrapper.vm.$nextTick();
670 | expect(spy).not.toHaveBeenCalled();
671 |
672 | spy.mockRestore();
673 | });
674 | });
675 | });
676 | });
677 |
678 | describe('Donut exports', () => {
679 | it('exports the plugin and component', () => {
680 | expect(DonutPlugin).toBeTruthy();
681 | expect(VcDonut).toEqual(DonutChart);
682 | });
683 |
684 | it('installs the plugin correctly', () => {
685 | const app = createApp({});
686 | app.component = vi.fn();
687 | app.use(DonutPlugin);
688 | expect(app.component).toHaveBeenCalledWith('VcDonut', DonutChart);
689 | });
690 | });
691 |
--------------------------------------------------------------------------------
/tests/unit/utils.spec.ts:
--------------------------------------------------------------------------------
1 | import { beforeAll, describe, expect, it, vi } from 'vitest';
2 | import { toPrecise, useElementSize } from '@/utils/misc';
3 | import { getDefaultColorDispatcher, defaultColors } from '@/utils/colors';
4 | import { ref } from 'vue';
5 | import { resizeObserverMock, timeout } from '../utils';
6 |
7 | describe('utils', () => {
8 | describe('toPrecise', () => {
9 | it('should correctly eliminate floating point errors', () => {
10 | expect(toPrecise(0.1 + 0.2)).toBe(0.3);
11 | });
12 | });
13 |
14 | describe('getDefaultColorDispatcher', () => {
15 | it('should return a function that dispatches colors in the correct order', () => {
16 | const dispatch = getDefaultColorDispatcher();
17 | defaultColors.forEach((color) => {
18 | expect(dispatch()).toBe(color);
19 | });
20 | });
21 |
22 | it('should wrap around and warn when it runs out of colors', () => {
23 | const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
24 |
25 | const dispatch = getDefaultColorDispatcher();
26 | defaultColors.forEach(() => dispatch()); // exhaust all colors
27 |
28 | expect(warnSpy).not.toHaveBeenCalled();
29 |
30 | const lastColor = dispatch();
31 | const lastCall = warnSpy.mock.calls[warnSpy.mock.calls.length - 1];
32 | expect(warnSpy).toHaveBeenCalled();
33 | expect(lastCall[0]).toContain('reusing colors');
34 | expect(lastColor).toBe(defaultColors[0]); // should wrap around
35 |
36 | warnSpy.mockRestore();
37 | });
38 | });
39 |
40 | describe('useElementSize', () => {
41 | beforeAll(() => {
42 | global.ResizeObserver = resizeObserverMock.mock;
43 | });
44 |
45 | it('attaches and detaches ResizeObserver as the element changes', async () => {
46 | const el = ref(document.createElement('div'));
47 | useElementSize({ el });
48 | expect(resizeObserverMock.mockFns.observe).toHaveBeenCalled();
49 | el.value = document.createElement('div');
50 | await timeout();
51 | expect(resizeObserverMock.mockFns.unobserve).toHaveBeenCalled();
52 | expect(resizeObserverMock.mockFns.observe).toHaveBeenCalledTimes(2);
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 |
3 | /**
4 | * selector constants for various elements in Donut and DonutSections components
5 | */
6 | export enum El {
7 | MAIN_CONTAINER = '.cdc-container', // main container div
8 | DONUT = '.cdc', // just the donut, without the container
9 | DONUT_OVERLAY = '.cdc-overlay', // center of the donut
10 | DONUT_OVERLAY_CONTENT = '.cdc-text', // this is where the default slot's content goes,
11 |
12 | DONUT_SECTIONS_CONTAINER = '.cdc-sections', // container div for all sections
13 | DONUT_SECTION = '.cdc-section', // container div for each section
14 | DONUT_SECTION_FILLER = '.cdc-filler', // div that has section background color
15 |
16 | LEGEND = '.cdc-legend', // div that contains all the legend items
17 | LEGEND_ITEM = '.cdc-legend-item', // individual legend items
18 | LEGEND_ITEM_COLOR = '.cdc-legend-item-color', // div that renders color for each legend item
19 | }
20 |
21 | /**
22 | * @param {string} hexString - hex color string of the format `#111111` or `111111`.
23 | * @returns {string} RGB color value as used in CSS
24 | */
25 | export const hexToCssRgb = (hexString: string) => {
26 | /* eslint-disable no-bitwise */
27 | // https://stackoverflow.com/a/11508164
28 | const hex = hexString.replace(/[^0-9A-F]/gi, '');
29 | const bigInt = parseInt(hex, 16);
30 | const r = (bigInt >> 16) & 255;
31 | const g = (bigInt >> 8) & 255;
32 | const b = bigInt & 255;
33 |
34 | return `rgb(${[r, g, b].join(', ')})`;
35 | };
36 |
37 | /**
38 | * Utility to trigger `resize` event in JSDom.
39 | * @param {*} [height] - value for `window.innerHeight`.
40 | * @param {*} [width] - value for `window.innerWidth`
41 | */
42 | export const triggerResize = function (height = 0, width = 0) {
43 | window.innerHeight = height || window.innerHeight;
44 | window.innerWidth = width || window.innerWidth;
45 | window.dispatchEvent(new Event('resize'));
46 | };
47 |
48 | /** Promisified setTimeout */
49 | export const timeout = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
50 |
51 | export const resizeObserverMock = (() => {
52 | const fns: Array = [];
53 |
54 | const observe = vi.fn();
55 | const unobserve = vi.fn();
56 | const disconnect = vi.fn();
57 | const mock = class ResizeObserver {
58 | constructor(cb: ResizeObserverCallback) {
59 | fns.push(cb);
60 | }
61 | observe = observe;
62 | unobserve = unobserve;
63 | disconnect = disconnect;
64 | };
65 |
66 | const dummyEntry: ResizeObserverEntry = {
67 | target: document.createElement('div'),
68 | contentRect: {
69 | width: 0,
70 | height: 0,
71 | top: 0,
72 | right: 0,
73 | bottom: 0,
74 | left: 0,
75 | x: 0,
76 | y: 0,
77 | toJSON: () => '',
78 | },
79 | borderBoxSize: [],
80 | contentBoxSize: [],
81 | devicePixelContentBoxSize: [],
82 | };
83 | const dummyResizeObserver = new mock(() => {});
84 | const trigger = () => {
85 | fns.forEach((fn) => fn([dummyEntry], dummyResizeObserver));
86 | return timeout();
87 | };
88 | return {
89 | mock,
90 | mockFns: { observe, unobserve, disconnect },
91 | trigger,
92 | };
93 | })();
94 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@vue/tsconfig/tsconfig.dom.json",
3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "package.json", "tests/**/*"],
4 | "exclude": ["src/**/__tests__/*"],
5 | "compilerOptions": {
6 | "composite": true,
7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
8 |
9 | "baseUrl": ".",
10 | "paths": {
11 | "@/*": ["./src/*"]
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.node.json"
6 | },
7 | {
8 | "path": "./tsconfig.app.json"
9 | },
10 | {
11 | "path": "./tsconfig.vitest.json"
12 | }
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@tsconfig/node20/tsconfig.json",
3 | "include": [
4 | "vite.config.*",
5 | "vitest.config.*",
6 | "cypress.config.*",
7 | "nightwatch.conf.*",
8 | "playwright.config.*"
9 | ],
10 | "compilerOptions": {
11 | "composite": true,
12 | "noEmit": true,
13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
14 | "allowImportingTsExtensions": true,
15 |
16 | "module": "ESNext",
17 | "moduleResolution": "Bundler",
18 | "types": ["node"],
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.vitest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.app.json",
3 | "exclude": [],
4 | "compilerOptions": {
5 | "composite": true,
6 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
7 |
8 | "lib": [],
9 | "types": ["node", "jsdom"]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/vite.config.mts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'node:url';
2 | import { resolve } from 'node:path';
3 |
4 | import { defineConfig, type UserConfig } from 'vite';
5 | import vue from '@vitejs/plugin-vue';
6 | import VueDevTools from 'vite-plugin-vue-devtools';
7 | import dts from 'vite-plugin-dts';
8 |
9 | const siteBuildConfig: UserConfig['build'] = {};
10 | const libBuildConfig: UserConfig['build'] = {
11 | lib: {
12 | entry: resolve(__dirname, 'src/lib.ts'),
13 | name: 'vcdonut',
14 | fileName: 'vcdonut',
15 | formats: ['es', 'umd'],
16 | },
17 | rollupOptions: {
18 | external: ['vue'],
19 | output: {
20 | globals: {
21 | vue: 'Vue',
22 | },
23 | assetFileNames: 'vcdonut.[ext]',
24 | // https://rollupjs.org/configuration-options/#output-exports
25 | exports: 'named',
26 | },
27 | },
28 | sourcemap: true,
29 | };
30 |
31 | const plugins: UserConfig['plugins'] = [vue(), VueDevTools()];
32 | if (process.env.BUILD_TARGET === 'lib') {
33 | plugins.push(
34 | dts({
35 | tsconfigPath: resolve(__dirname, 'tsconfig.app.json'),
36 | rollupTypes: true,
37 | }),
38 | );
39 | }
40 |
41 | // https://vitejs.dev/config/
42 | export default defineConfig({
43 | base: process.env.BUILD_TARGET === 'site' ? '/vue-css-donut-chart/' : undefined,
44 | plugins,
45 | build: (() => {
46 | if (process.env.BUILD_TARGET === 'site') return siteBuildConfig;
47 | if (process.env.BUILD_TARGET === 'lib') return libBuildConfig;
48 | return undefined;
49 | })(),
50 | resolve: {
51 | alias: {
52 | '@': fileURLToPath(new URL('./src', import.meta.url)),
53 | },
54 | },
55 | });
56 |
--------------------------------------------------------------------------------
/vitest.config.mts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath } from 'node:url';
2 | import { mergeConfig, defineConfig, configDefaults } from 'vitest/config';
3 | import viteConfig from './vite.config.mts';
4 |
5 | export default mergeConfig(
6 | viteConfig,
7 | defineConfig({
8 | test: {
9 | environment: 'jsdom',
10 | exclude: [...configDefaults.exclude, 'e2e/**'],
11 | root: fileURLToPath(new URL('./', import.meta.url)),
12 | coverage: {
13 | exclude: [
14 | ...(configDefaults.coverage.exclude || []),
15 | 'src/components/site/**',
16 | 'tests/**',
17 | 'src/App.vue',
18 | 'src/main.ts',
19 | ],
20 | provider: 'istanbul',
21 | reporter: ['text', 'json', 'html'],
22 | },
23 | },
24 | }),
25 | );
26 |
--------------------------------------------------------------------------------