├── .editorconfig ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example ├── index.html ├── index.tsx └── style.css ├── jest.config.js ├── jest.setup.js ├── lib └── index.js ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── circle-slider.test.tsx.snap │ └── circle-slider.test.tsx ├── circle-slider │ ├── helpers │ │ ├── circle-slider-helper │ │ │ └── index.ts │ │ ├── mouse-helper │ │ │ └── index.ts │ │ └── path-generator │ │ │ └── index.ts │ └── index.tsx └── index.ts ├── tsconfig.jest.json ├── tsconfig.json ├── tslint.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset=utf-8 3 | end_of_line=crlf 4 | insert_final_newline=true 5 | indent_style=space 6 | indent_size=4 7 | 8 | [{.babelrc,.stylelintrc,.eslintrc,*.json,*.jsb3,*.jsb2,*.bowerrc}] 9 | indent_style=space 10 | indent_size=2 11 | 12 | [tslint.json] 13 | indent_style=space 14 | indent_size=2 15 | 16 | [{.analysis_options,*.yml,*.yaml}] 17 | indent_style=space 18 | indent_size=2 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General files 2 | *~ 3 | .DS_Store 4 | 5 | # Build files 6 | /styleguide/ 7 | /dist/ 8 | /build/ 9 | .cache 10 | 11 | # Development stuff 12 | node_modules 13 | *.sublime-workspace 14 | *.pyc 15 | .vagrant 16 | .idea 17 | .vscode 18 | npm-debug.* 19 | 20 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 21 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 22 | 23 | # User-specific stuff: 24 | .idea/**/workspace.xml 25 | .idea/**/tasks.xml 26 | 27 | # Sensitive or high-churn files: 28 | .idea/**/dataSources/ 29 | .idea/**/dataSources.ids 30 | .idea/**/dataSources.xml 31 | .idea/**/dataSources.local.xml 32 | .idea/**/sqlDataSources.xml 33 | .idea/**/dynamic.xml 34 | .idea/**/uiDesigner.xml 35 | 36 | # Gradle: 37 | .idea/**/gradle.xml 38 | .idea/**/libraries 39 | 40 | # Mongo Explorer plugin: 41 | .idea/**/mongoSettings.xml 42 | 43 | ## File-based project format: 44 | *.iws 45 | 46 | ## Plugin-specific files: 47 | 48 | # IntelliJ 49 | /out/ 50 | 51 | # mpeltonen/sbt-idea plugin 52 | .idea_modules/ 53 | 54 | # JIRA plugin 55 | atlassian-ide-plugin.xml 56 | 57 | # Crashlytics plugin (for Android Studio and IntelliJ) 58 | com_crashlytics_export_strings.xml 59 | crashlytics.properties 60 | crashlytics-build.properties 61 | fabric.properties 62 | 63 | # Logs 64 | logs 65 | *.log 66 | npm-debug.log* 67 | 68 | /coverage/ 69 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache 4 | example -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "trailingComma": "all", 4 | "printWidth": 80 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | script: 5 | - npm run build 6 | deploy: 7 | provider: pages 8 | local-dir: dist 9 | skip_cleanup: true 10 | github_token: $GITHUB_TOKEN 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## 1.6.2 6 | 7 | - Fixed issues to make the slider work in IE 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2015-present Alipay.com, https://www.alipay.com/ 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🕹️ react-circle-slider 2 | 3 | Circle Slider Component for React.js 4 | 5 | ![Imgur](https://i.imgur.com/4RdYfaL.gif) 6 | 7 | ## ⚡ Functionality 8 | 9 | - Simple to use 10 | - No extra dependencies 11 | - Highly customizable 12 | - Defining min and max values 13 | - Defining step size 14 | - Defining gradient color 15 | - Touch support 16 | - Tooltip support 17 | - Style based: no images / SVGs 18 | 19 | ## Examples 20 | 21 | - To check out live examples visit https://dmitrymorozoff.github.io/react-circle-slider/ 22 | 23 | ## 🚀 Getting started 24 | 25 | Install `react-circle-slider` using npm. 26 | 27 | ### `npm install --save react-circle-slider` 28 | 29 | 30 | You can also test the components locally by cloning this repo and doing the following steps: 31 | 32 | ## 🔲 NPM-scripts 33 | 34 | Install dependencies from package.json: 35 | 36 | ### `npm install` 37 | 38 | Runs the app in the development mode.
39 | Open [http://localhost:1234](http://localhost:1234) to view it in the browser. 40 | 41 | ### `npm run dev` 42 | 43 | Run linter 44 | 45 | ### `npm run lint` 46 | 47 | Start tests followed by jest 48 | 49 | ### `npm run test` 50 | 51 | ## Usage 52 | 53 | ```jsx 54 | import React from "react"; 55 | import ReactDOM from "react-dom"; 56 | import { CircleSlider } from "react-circle-slider"; 57 | 58 | export class App extends React.Component { 59 | constructor(props) { 60 | super(props); 61 | this.state = { value: 0 }; 62 | } 63 | 64 | handleChange = value => { 65 | console.log(`Changed value ${value}`); 66 | this.setState({ value }); 67 | }; 68 | 69 | handleChangeRange = event => { 70 | this.setState({ 71 | value: event.target.valueAsNumber, 72 | }); 73 | }; 74 | 75 | render() { 76 | const { value } = this.state; 77 | return ( 78 | 79 | ); 80 | } 81 | } 82 | 83 | ReactDOM.render(, document.getElementById("root")); 84 | ``` 85 | 86 | ## 📃 Props 87 | 88 | | Props | Type | Default | Description | 89 | | ----------------- | :------- | --------- | -------------------------------------------------------------- | 90 | | size | Number | 180 | size of the slider in px | 91 | | stepSize | Number | 1 | value to be added or subtracted on each step the slider makes. | 92 | | knobRadius | Number | null | knob radius in px | 93 | | circleWidth | Number | null | width of circle in px | 94 | | progressWidth | Number | null | progress curve width in px | 95 | | min | Number | 0 | the minimum value of the slider | 96 | | max | Number | 100 | the maximum value of the slider | 97 | | value | Number | 0 | value | 98 | | circleColor | String | `#e9eaee` | color of slider | 99 | | progressColor | String | `#007aff` | color of progress curve | 100 | | gradientColorFrom | String | NOOP | start gradient color of progress curve | 101 | | gradientColorTo | String | NOOP | end gradient color progress curve | 102 | | knobColor | String | `#fff` | color of knob | 103 | | disabled | Boolean | false | disabled status | 104 | | shadow | Boolean | true | shadow on knob | 105 | | showTooltip | Boolean | false | tooltip | 106 | | showPercentage | Boolean | false | percentage on tooltip | 107 | | tooltipSize | Number | 32 | size of tooltip | 108 | | tooltipColor | String | `#333` | color of tooltip | 109 | | onChange | Function | NOOP | when slider is moved, `onChange` is triggered. | 110 | 111 | ## 💡 Todo 112 | 113 | - [ ] Keyboard support 114 | - [ ] Mouse scroll support 115 | - [ ] Accessibility 116 | 117 | ## 💻 Contributing 118 | 119 | - For bugs and feature requests, please create an issue 120 | - Lint and test your code 121 | - Pull requests and ⭐ stars are always welcome 122 | 123 | ## License 124 | 125 | MIT -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | react-circle-slider 11 | 29 | 30 | 31 | 32 |
33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import SyntaxHighlighter from "react-syntax-highlighter/prism"; 4 | import { ghcolors } from "react-syntax-highlighter/styles/prism"; 5 | import { CircleSlider } from "../src/circle-slider"; 6 | 7 | interface IState { 8 | value: number; 9 | } 10 | 11 | export class App extends React.Component<{}, IState> { 12 | constructor(props: any) { 13 | super(props); 14 | this.state = { value: 50 }; 15 | } 16 | public handleChange = (value: any) => { 17 | this.setState({ value }); 18 | }; 19 | 20 | public handleChangeRange = (event: any) => { 21 | this.setState({ 22 | value: event.target.valueAsNumber, 23 | }); 24 | }; 25 | 26 | public render() { 27 | const { value } = this.state; 28 | const codeString = `npm install --save react-circle-slider`; 29 | return ( 30 |
31 |
32 |

react-circle-slider

33 |
34 | Circle Slider Component for React.js 35 |
36 |
37 |
38 |
39 | 44 | {codeString} 45 | 46 |
47 |

Default

48 |
49 |
50 | 54 |
{value}
55 |
56 | 64 |
{" "} 65 |
66 |
67 | 72 | {``} 73 | 74 |
75 |
76 |

Custom progress color

77 |
78 |
79 | 84 |
{value}
85 |
86 | 94 |
95 |
96 |
97 | 98 | {``} 102 | 103 |
104 |
105 |

Custom gradient color

106 |
107 |
108 | 116 |
{value}
117 |
118 | 126 |
127 |
128 |
129 | 130 | {``} 137 | 138 |
139 |
140 |

Colors

141 |
142 |
143 | 155 |
{value}
156 |
157 | 165 |
166 |
167 |
168 | 169 | {``} 180 | 181 |
182 |
183 |

Shadow

184 |
185 |
186 | 196 |
{value}
197 |
198 | 206 |
207 |
208 |
209 | 210 | {``} 219 | 220 |
221 |
222 |

Sizes

223 |
224 |
225 | 234 |
{value}
235 |
236 | 244 |
245 |
246 |
247 | 248 | {``} 256 | 257 |
258 |
259 |

Exact sizes

260 |
261 |
262 | 271 |
{value}
272 |
273 | 281 |
282 |
283 |
284 | 285 | {``} 293 | 294 |
295 |
296 |
297 |
298 | ); 299 | } 300 | } 301 | 302 | ReactDOM.render(, document.getElementById("root")); 303 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: center; 5 | justify-content: center; 6 | margin: 0 auto; 7 | width: 900px; 8 | } 9 | 10 | .header { 11 | display: flex; 12 | flex-direction: column; 13 | width: 100%; 14 | align-items: center; 15 | justify-content: center; 16 | height: 200px; 17 | background-color: #017aff; 18 | } 19 | 20 | .header-title { 21 | color: #fff; 22 | font-size: 2.125rem; 23 | letter-spacing: 1rem; 24 | text-transform: uppercase; 25 | text-align: center; 26 | margin-bottom: 1rem; 27 | } 28 | 29 | .header-subtitle { 30 | color: #fff; 31 | font-size: 16px; 32 | text-align: center; 33 | opacity: 0.6; 34 | } 35 | 36 | .wrapper { 37 | width: 100%; 38 | display: flex; 39 | align-items: center; 40 | margin: 2rem 0; 41 | border: 1px solid #e9eaee; 42 | padding: 2rem; 43 | box-sizing: border-box; 44 | box-shadow: 0 70px 63px -60px #eeeeee; 45 | } 46 | 47 | .slider { 48 | width: 40%; 49 | display: flex; 50 | flex-direction: column; 51 | justify-content: center; 52 | align-items: center; 53 | } 54 | 55 | .title { 56 | text-align: center; 57 | font-family: sans-serif; 58 | margin-top: 0.5rem; 59 | font-size: 20px; 60 | } 61 | 62 | .range { 63 | display: flex; 64 | justify-content: center; 65 | margin-top: 1rem; 66 | } 67 | 68 | .code { 69 | width: 100%; 70 | margin: 1rem 0 1.5rem 0; 71 | font-size: 18px; 72 | } 73 | 74 | .code>pre>code { 75 | line-height: 1.5rem !important; 76 | } 77 | 78 | h3 { 79 | font-weight: normal; 80 | font-size: 28px; 81 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const TEST_REGEX = "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|js?|tsx?|ts?)$"; 2 | 3 | module.exports = { 4 | setupFiles: ["/jest.setup.js"], 5 | snapshotSerializers: ["/node_modules/enzyme-to-json/serializer"], 6 | globals: { 7 | "ts-jest": { 8 | // useBabelrc: true, 9 | tsConfigFile: "tsconfig.jest.json", 10 | }, 11 | }, 12 | testRegex: TEST_REGEX, 13 | transform: { 14 | "^.+\\.tsx?$": "ts-jest", 15 | }, 16 | testPathIgnorePatterns: ["/node_modules/"], 17 | moduleFileExtensions: ["ts", "tsx", "js", "jsx"], 18 | }; 19 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | const Enzyme = require("enzyme"); 2 | const Adapter = require("enzyme-adapter-react-16"); 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | !function(e,t){for(var r in t)e[r]=t[r]}(exports,function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}return r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=0)}([function(e,t,r){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var n=r(1);t.CircleSlider=n.CircleSlider},function(e,t,r){"use strict";var n,o=this&&this.__extends||(n=function(e,t){return(n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var r in t)t.hasOwnProperty(r)&&(e[r]=t[r])})(e,t)},function(e,t){function r(){this.constructor=e}n(e,t),e.prototype=null===t?Object.create(t):(r.prototype=t.prototype,new r)});Object.defineProperty(t,"__esModule",{value:!0});var i=r(2),u=r(5),l=r(6),a=r(7),s=function(e){function t(t){var r=e.call(this,t)||this;r.updateAngle=function(e){r.circleSliderHelper.updateStepIndexFromAngle(e);var t=r.circleSliderHelper.getCurrentStep();r.setState({angle:e,currentStepValue:t}),r.props.onChange(t)},r.updateSlider=function(){var e=r.mouseHelper.getNewSliderAngle();Math.abs(e-r.state.angle)k.length&&k.push(e)}function T(e,t,r){return null==e?0:function e(t,r,n,o){var l=typeof t;"undefined"!==l&&"boolean"!==l||(t=null);var a=!1;if(null===t)a=!0;else switch(l){case"string":case"number":a=!0;break;case"object":switch(t.$$typeof){case i:case u:a=!0}}if(a)return n(o,t,""===r?"."+F(t,0):r),1;if(a=0,r=""===r?".":r+":",Array.isArray(t))for(var s=0;s", 27 | "license": "MIT", 28 | "devDependencies": { 29 | "@babel/core": "^7.2.2", 30 | "@babel/preset-env": "^7.2.3", 31 | "@types/enzyme": "^3.1.13", 32 | "@types/enzyme-adapter-react-16": "^1.0.3", 33 | "@types/enzyme-to-json": "^1.5.2", 34 | "@types/jest": "^23.3.1", 35 | "@types/react": "^16.4.11", 36 | "@types/react-dom": "^16.0.7", 37 | "@types/react-syntax-highlighter": "0.0.6", 38 | "awesome-typescript-loader": "^5.2.1", 39 | "babel-loader": "^8.0.5", 40 | "enzyme": "^3.5.0", 41 | "enzyme-adapter-react-16": "^1.2.0", 42 | "enzyme-to-json": "^3.3.4", 43 | "jest": "^23.5.0", 44 | "parcel-bundler": "^1.9.7", 45 | "prettier": "^1.14.2", 46 | "react-addons-test-utils": "^15.5.1", 47 | "react-syntax-highlighter": "^9.0.1", 48 | "rimraf": "^2.6.2", 49 | "sinon": "^6.1.5", 50 | "source-map-loader": "^0.2.4", 51 | "ts-jest": "^23.1.4", 52 | "ts-loader": "^4.5.0", 53 | "tslint": "^5.11.0", 54 | "tslint-config-prettier": "^1.14.0", 55 | "tslint-react": "^3.6.0", 56 | "typescript": "^3.0.1", 57 | "uglifyjs-webpack-plugin": "^1.3.0", 58 | "webpack": "^4.28.4", 59 | "webpack-cli": "^3.1.0" 60 | }, 61 | "dependencies": { 62 | "react": "^16.7.0", 63 | "react-dom": "^16.0.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/circle-slider.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`circle slider should render a default circle slider 1`] = ` 4 | 17 | 18 | 30 | 41 | 45 | 49 | 53 | 54 | 58 | 59 | 60 | 61 | 64 | 65 | 66 | 78 | 79 | 80 | `; 81 | -------------------------------------------------------------------------------- /src/__tests__/circle-slider.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CircleSlider } from "../circle-slider"; 3 | import { shallow, mount } from "enzyme"; 4 | 5 | describe("circle slider", () => { 6 | const props = { 7 | circleColor: "#EDEDED", 8 | size: 100, 9 | value: 0, 10 | progressColor: "#ADA1FB", 11 | knobColor: "#ADA1FB", 12 | circleWidthInit: 9, 13 | progressWidthInit: 7, 14 | knobRadiusInit: 6, 15 | stepSize: 1, 16 | min: 0, 17 | max: 100, 18 | onChange: jest.fn(), 19 | }; 20 | 21 | it("circle slider should render a svg", () => { 22 | const circleSlider = shallow(); 23 | 24 | expect(circleSlider.find("svg")).toHaveLength(1); 25 | }); 26 | 27 | it("circle slider should render with props", () => { 28 | const circleSlider = mount(); 29 | 30 | expect(circleSlider.props().circleColor).toEqual("#EDEDED"); 31 | expect(circleSlider.props().value).toEqual(0); 32 | expect(circleSlider.props().progressColor).toEqual("#ADA1FB"); 33 | expect(circleSlider.props().knobColor).toEqual("#ADA1FB"); 34 | expect(circleSlider.props().circleWidthInit).toEqual(9); 35 | expect(circleSlider.props().progressWidthInit).toEqual(7); 36 | expect(circleSlider.props().knobRadiusInit).toEqual(6); 37 | expect(circleSlider.props().stepSize).toEqual(1); 38 | expect(circleSlider.props().min).toEqual(0); 39 | expect(circleSlider.props().max).toEqual(100); 40 | }); 41 | 42 | it("circle slider should call onChange", () => { 43 | const circleSlider = shallow(); 44 | (circleSlider.instance() as any).updateAngle(); 45 | 46 | expect(props.onChange).toHaveBeenCalled; 47 | }); 48 | 49 | it("circle slider should call onChange", () => { 50 | const circleSlider = shallow(); 51 | (circleSlider.instance() as any).updateAngle(); 52 | 53 | expect(props.onChange).toHaveBeenCalled; 54 | }); 55 | 56 | it("circle slider should call mouseDown", () => { 57 | const circleSlider = shallow(); 58 | const accuracy = 0.00001; 59 | circleSlider.simulate("mousedown", { 60 | preventDefault: () => {}, 61 | }); 62 | 63 | expect(circleSlider.state("angle")).toEqual(0 - accuracy); 64 | expect(circleSlider.state("currentStepValue")).toEqual(0); 65 | }); 66 | 67 | it("circle slider current step value to be equals 5", () => { 68 | const circleSlider = shallow(); 69 | const instance = circleSlider.instance() as any; 70 | instance.updateAngle(0.3125); 71 | 72 | expect(circleSlider.state("currentStepValue")).toEqual(5); 73 | }); 74 | 75 | it("should render a default circle slider", () => { 76 | const circleSlider = shallow(); 77 | expect(circleSlider).toMatchSnapshot(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/circle-slider/helpers/circle-slider-helper/index.ts: -------------------------------------------------------------------------------- 1 | interface INumber { 2 | EPSILON: any; 3 | } 4 | declare var Number: INumber; 5 | 6 | export class CircleSliderHelper { 7 | private stepsArray: number[]; 8 | private stepIndex: number; 9 | private countSteps: number; 10 | 11 | constructor(stepsArray: number[], initialValue: any) { 12 | this.stepsArray = stepsArray; 13 | this.countSteps = this.stepsArray.length - 1; 14 | this.stepIndex = 0; 15 | this.setCurrentStepIndexFromArray(initialValue); 16 | } 17 | 18 | public getEpsilon() { 19 | let epsilon = 1.0; 20 | while (1.0 + 0.5 * epsilon !== 1.0) { 21 | epsilon *= 0.5; 22 | } 23 | return epsilon; 24 | } 25 | 26 | public getAngle(): number { 27 | const accuracy = 0.00001; 28 | const epsilon = Number.EPSILON || this.getEpsilon(); 29 | return ( 30 | Math.min( 31 | this.getAnglePoint() * this.stepIndex, 32 | 2 * Math.PI - epsilon, 33 | ) - accuracy 34 | ); 35 | } 36 | 37 | public getCurrentStep(): number { 38 | return this.stepsArray[this.stepIndex]; 39 | } 40 | 41 | public updateStepIndexFromValue(value: number) { 42 | const isSetValue = this.setCurrentStepIndexFromArray(value); 43 | if (isSetValue) { 44 | return; 45 | } 46 | this.stepIndex = this.countSteps; 47 | } 48 | 49 | public updateStepIndexFromAngle(angle: number) { 50 | const stepIndex = Math.round(angle / this.getAnglePoint()); 51 | if (stepIndex < this.countSteps) { 52 | this.stepIndex = stepIndex; 53 | return; 54 | } 55 | this.stepIndex = this.countSteps; 56 | } 57 | 58 | public setCurrentStepIndexFromArray = (value: number): boolean => { 59 | for (let i = 0; i < this.countSteps; i++) { 60 | if (value <= this.stepsArray[i]) { 61 | this.stepIndex = i; 62 | return true; 63 | } 64 | } 65 | this.stepIndex = this.countSteps; 66 | return false; 67 | }; 68 | 69 | public getAnglePoint(): number { 70 | return (Math.PI * 2) / this.countSteps; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/circle-slider/helpers/mouse-helper/index.ts: -------------------------------------------------------------------------------- 1 | export class MouseHelper { 2 | private container: any; 3 | private center!: number; 4 | private relativeX!: number; 5 | private relativeY!: number; 6 | 7 | constructor(container: any) { 8 | this.container = container; 9 | this.setPosition({ x: 0, y: 0 }); 10 | } 11 | 12 | public setPosition(event: any): void { 13 | if (!this.container) { 14 | return; 15 | } 16 | const rectSize = this.container.getBoundingClientRect(); 17 | const width = rectSize.width; 18 | this.center = width / 2; 19 | this.relativeX = event.clientX - rectSize.left; 20 | this.relativeY = event.clientY - rectSize.top; 21 | } 22 | 23 | public getNewSliderAngle(): number { 24 | const angleBetweenTwoVectors = Math.atan2( 25 | this.relativeY - this.center, 26 | this.relativeX - this.center, 27 | ); 28 | return (angleBetweenTwoVectors + (3 * Math.PI) / 2) % (2 * Math.PI); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/circle-slider/helpers/path-generator/index.ts: -------------------------------------------------------------------------------- 1 | export const pathGenerator = ( 2 | center: number, 3 | radius: number, 4 | direction: number, 5 | x: number, 6 | y: number, 7 | ): string => { 8 | const points = []; 9 | points.push("M" + center); 10 | points.push(center + radius); 11 | points.push("A"); 12 | points.push(radius); 13 | points.push(radius); 14 | points.push(0); 15 | points.push(direction); 16 | points.push(1); 17 | points.push(x); 18 | points.push(y); 19 | return points.join(" "); 20 | }; 21 | -------------------------------------------------------------------------------- /src/circle-slider/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { CircleSliderHelper } from "./helpers/circle-slider-helper"; 3 | import { MouseHelper } from "./helpers/mouse-helper"; 4 | import { pathGenerator } from "./helpers/path-generator"; 5 | 6 | interface IProps { 7 | size?: number; 8 | circleWidth?: number; 9 | progressWidth?: number; 10 | knobRadius?: number; 11 | value?: number; 12 | stepSize?: number; 13 | min?: number; 14 | max?: number; 15 | circleColor?: string; 16 | progressColor?: string; 17 | gradientColorFrom?: string; 18 | gradientColorTo?: string; 19 | knobColor?: string; 20 | onChange: ((value?: number) => void); 21 | disabled?: boolean; 22 | shadow?: boolean; 23 | showTooltip?: boolean; 24 | showPercentage?: boolean; 25 | tooltipSize?: number; 26 | tooltipColor?: string; 27 | } 28 | 29 | interface IPoint { 30 | x: number; 31 | y: number; 32 | } 33 | 34 | interface IState { 35 | angle: number; 36 | currentStepValue: number; 37 | isMouseMove: boolean; 38 | } 39 | 40 | export class CircleSlider extends React.Component { 41 | public static defaultProps: Partial = { 42 | circleColor: "#e9eaee", 43 | size: 180, 44 | value: 0, 45 | progressColor: "#007aff", 46 | knobColor: "#fff", 47 | circleWidth: 5, 48 | progressWidth: 20, 49 | knobRadius: 20, 50 | stepSize: 1, 51 | min: 0, 52 | max: 100, 53 | disabled: false, 54 | shadow: true, 55 | showTooltip: false, 56 | showPercentage: false, 57 | tooltipSize: 32, 58 | tooltipColor: "#333", 59 | onChange: () => ({}), 60 | }; 61 | private maxLineWidth: number; 62 | private radius: number; 63 | private countSteps: number; 64 | private stepsArray: number[]; 65 | private circleSliderHelper: CircleSliderHelper; 66 | private mouseHelper!: MouseHelper; 67 | private svg: any; 68 | 69 | constructor(props: IProps) { 70 | super(props); 71 | this.state = { 72 | angle: 0, 73 | currentStepValue: 0, 74 | isMouseMove: false, 75 | }; 76 | 77 | const { 78 | min, 79 | max, 80 | stepSize, 81 | value, 82 | circleWidth, 83 | progressWidth, 84 | knobRadius, 85 | } = this.props; 86 | 87 | this.maxLineWidth = Math.max(circleWidth!, progressWidth!); 88 | this.radius = 89 | this.getCenter() - Math.max(this.maxLineWidth, knobRadius! * 2) / 2; 90 | this.countSteps = 1 + (max! - min!) / stepSize!; 91 | this.stepsArray = this.getStepsArray(min!, stepSize!); 92 | 93 | this.circleSliderHelper = new CircleSliderHelper( 94 | this.stepsArray, 95 | value, 96 | ); 97 | } 98 | 99 | public componentDidMount() { 100 | this.mouseHelper = new MouseHelper(this.svg); 101 | this.setState({ 102 | angle: this.circleSliderHelper.getAngle(), 103 | currentStepValue: this.circleSliderHelper.getCurrentStep(), 104 | }); 105 | } 106 | 107 | public componentWillReceiveProps(nextProps: any) { 108 | if (this.props.value !== nextProps.value && !this.state.isMouseMove) { 109 | this.updateSliderFromProps(nextProps.value); 110 | } 111 | } 112 | 113 | public updateAngle = (angle: number): void => { 114 | this.circleSliderHelper.updateStepIndexFromAngle(angle); 115 | const currentStep = this.circleSliderHelper.getCurrentStep(); 116 | this.setState({ 117 | angle, 118 | currentStepValue: currentStep, 119 | }); 120 | this.props.onChange(currentStep); 121 | }; 122 | 123 | public updateSlider = (): void => { 124 | const angle = this.mouseHelper.getNewSliderAngle(); 125 | if (Math.abs(angle - this.state.angle) < Math.PI) { 126 | this.updateAngle(angle); 127 | } 128 | }; 129 | 130 | public updateSliderFromProps = (valueFromProps: number): void => { 131 | const { stepSize } = this.props; 132 | const newValue = Math.round(valueFromProps / stepSize!) * stepSize!; 133 | this.circleSliderHelper.updateStepIndexFromValue(newValue); 134 | this.setState({ 135 | angle: this.circleSliderHelper.getAngle(), 136 | currentStepValue: newValue, 137 | }); 138 | }; 139 | 140 | public getCenter = (): number => { 141 | return this.props.size! / 2; 142 | }; 143 | 144 | public getAngle = (): number => { 145 | return this.state.angle + Math.PI / 2; 146 | }; 147 | 148 | public getPointPosition = (): IPoint => { 149 | const center = this.getCenter(); 150 | const angle = this.getAngle(); 151 | return { 152 | x: center + this.radius * Math.cos(angle), 153 | y: center + this.radius * Math.sin(angle), 154 | }; 155 | }; 156 | 157 | public getStepsArray = (min: number, stepSize: number): number[] => { 158 | const stepArray = []; 159 | for (let i = 0; i < this.countSteps; i++) { 160 | stepArray.push(min + i * stepSize); 161 | } 162 | return stepArray; 163 | }; 164 | 165 | public getPath = (): string => { 166 | const center = this.getCenter(); 167 | const direction = this.getAngle() < 1.5 * Math.PI ? 0 : 1; 168 | const { x, y } = this.getPointPosition(); 169 | const path = pathGenerator(center, this.radius, direction, x, y); 170 | return path; 171 | }; 172 | 173 | public handleMouseMove = (event: Event): void => { 174 | event.preventDefault(); 175 | this.setState({ 176 | isMouseMove: true, 177 | }); 178 | this.mouseHelper.setPosition(event); 179 | this.updateSlider(); 180 | }; 181 | 182 | public handleMouseUp = (event: Event): void => { 183 | event.preventDefault(); 184 | this.setState({ 185 | isMouseMove: false, 186 | }); 187 | window.removeEventListener("mousemove", this.handleMouseMove); 188 | window.removeEventListener("mouseup", this.handleMouseUp); 189 | }; 190 | 191 | public handleMouseDown = (event: React.MouseEvent): void => { 192 | if (!this.props.disabled) { 193 | event.preventDefault(); 194 | window.addEventListener("mousemove", this.handleMouseMove); 195 | window.addEventListener("mouseup", this.handleMouseUp); 196 | } 197 | }; 198 | public handleTouchMove: any = ( 199 | event: React.TouchEvent, 200 | ): void => { 201 | const targetTouches = event.targetTouches; 202 | const countTouches = targetTouches.length; 203 | const currentTouch: React.Touch = targetTouches.item(countTouches - 1)!; 204 | this.mouseHelper.setPosition(currentTouch); 205 | this.updateSlider(); 206 | }; 207 | 208 | public handleTouchUp = (): void => { 209 | window.removeEventListener("touchmove", this.handleTouchMove); 210 | window.removeEventListener("touchend", this.handleTouchUp); 211 | }; 212 | 213 | public handleTouchStart = (): void => { 214 | if (!this.props.disabled) { 215 | window.addEventListener("touchmove", this.handleTouchMove); 216 | window.addEventListener("touchend", this.handleTouchUp); 217 | } 218 | }; 219 | 220 | public render() { 221 | const { 222 | size, 223 | progressColor, 224 | gradientColorFrom, 225 | gradientColorTo, 226 | knobColor, 227 | circleColor, 228 | disabled, 229 | shadow, 230 | circleWidth, 231 | progressWidth, 232 | knobRadius, 233 | showTooltip, 234 | showPercentage, 235 | tooltipSize, 236 | tooltipColor, 237 | } = this.props; 238 | const { currentStepValue } = this.state; 239 | const offset = shadow ? "5px" : "0px"; 240 | const { x, y } = this.getPointPosition(); 241 | const center = this.getCenter(); 242 | const isAllGradientColorsAvailable = 243 | gradientColorFrom && gradientColorTo; 244 | return ( 245 | (this.svg = svg)} 247 | width={`${size}px`} 248 | height={`${size}px`} 249 | viewBox={`0 0 ${size} ${size}`} 250 | onMouseDown={this.handleMouseDown} 251 | onTouchStart={this.handleTouchStart} 252 | style={{ 253 | padding: offset, 254 | boxSizing: "border-box", 255 | }} 256 | > 257 | 258 | 268 | {isAllGradientColorsAvailable && ( 269 | 270 | 277 | 281 | 285 | 286 | 287 | )} 288 | 299 | {shadow && ( 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | )} 312 | 322 | {showTooltip && ( 323 | 331 | {showPercentage 332 | ? `${currentStepValue}%` 333 | : currentStepValue} 334 | 335 | )} 336 | 337 | 338 | ); 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { CircleSlider } from "./circle-slider"; 2 | 3 | export { CircleSlider }; 4 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "jsx": "react" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "moduleResolution": "node", 6 | "downlevelIteration": true, 7 | "strict": true, 8 | "strictFunctionTypes": false, 9 | "types": ["node"], 10 | "sourceMap": true, 11 | "allowJs": true, 12 | "allowUnreachableCode": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "jsx": "react", 16 | "noUnusedLocals": true, 17 | "baseUrl": ".", 18 | "skipLibCheck": true 19 | }, 20 | "exclude": [ 21 | "example", 22 | "node_modules", 23 | "**/*.spec.ts", 24 | "**/*.spec.tsx", 25 | "**/*.test.ts", 26 | "**/*.test.tsx" 27 | ], 28 | "compileOnSave": false 29 | } 30 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "node_modules/**", 6 | "**/*.spec.ts", 7 | "**/*.spec.tsx", 8 | "**/*.test.ts", 9 | "**/*.test.tsx" 10 | ] 11 | }, 12 | "rules": { 13 | "triple-equals": true, 14 | "no-var-requires": false, 15 | "space-before-function-paren": [ 16 | true, 17 | { 18 | "anonymous": "never", 19 | "named": "never", 20 | "asyncArrow": "always", 21 | "method": "never", 22 | "constructor": "never" 23 | } 24 | ], 25 | "object-literal-sort-keys": false, 26 | "no-debugger": false, 27 | "member-ordering": [ 28 | true, 29 | { 30 | "order": [ 31 | "public-static-field", 32 | "protected-static-field", 33 | "private-static-field", 34 | "public-instance-field", 35 | "protected-instance-field", 36 | "private-instance-field", 37 | "public-constructor", 38 | "protected-constructor", 39 | "private-constructor", 40 | "public-static-method", 41 | "protected-static-method", 42 | "private-static-method", 43 | "public-instance-method", 44 | "protected-instance-method", 45 | "private-instance-method" 46 | ] 47 | } 48 | ], 49 | "align": [true, "statements"], 50 | "jsx-no-multiline-js": false, 51 | "no-default-export": true 52 | }, 53 | "jsRules": { 54 | "triple-equals": true, 55 | "no-var-requires": false, 56 | "space-before-function-paren": [ 57 | true, 58 | { 59 | "anonymous": "never", 60 | "named": "never", 61 | "asyncArrow": "always", 62 | "method": "never", 63 | "constructor": "never" 64 | } 65 | ], 66 | "object-literal-sort-keys": false, 67 | "no-debugger": false, 68 | "no-console": [false], 69 | "eofline": false, 70 | "jsx-no-multiline-js": false, 71 | "no-default-export": true 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | 4 | module.exports = { 5 | entry: path.resolve(__dirname, "src/index.ts"), 6 | output: { 7 | path: path.resolve(__dirname, "./lib"), 8 | filename: "index.js", 9 | library: "", 10 | libraryTarget: "commonjs", 11 | }, 12 | resolve: { 13 | extensions: [".js", ".jsx", ".json", ".ts", ".tsx"], 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.(ts|tsx)$/, 19 | loader: ["babel-loader", "ts-loader"], 20 | }, 21 | ], 22 | }, 23 | }; 24 | --------------------------------------------------------------------------------