├── .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 |
2 |

3 | 4 | 5 | 6 |

7 |

vue-css-donut-chart

8 |

Lightweight Vue component for creating donut charts

9 | 10 | 11 | 12 | npm 13 | 14 | 15 | npm monthly downloads 16 | 17 | 18 | npm bundle size 19 | 20 | 21 | Coverage status 22 | 23 |
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 | 78 | 85 | ``` 86 | 87 | #### Usage with all available props and events 88 | 89 | ```vue 90 | 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 | 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 | 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 | 12 | 13 | 17 | -------------------------------------------------------------------------------- /src/components/DonutChart.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 254 | -------------------------------------------------------------------------------- /src/components/DonutSections.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 153 | -------------------------------------------------------------------------------- /src/components/site/ProductBadges.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 41 | -------------------------------------------------------------------------------- /src/components/site/ProjectDemo.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------