├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.js ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── main.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── index.less ├── docs ├── changelog.md ├── demo │ ├── debug.md │ ├── editable.md │ ├── handle.md │ ├── marks.md │ ├── mulitple.md │ ├── range.md │ ├── slider.md │ └── vertical.md ├── examples │ ├── components │ │ └── TooltipSlider.tsx │ ├── debug.tsx │ ├── editable.tsx │ ├── handle.tsx │ ├── marks.tsx │ ├── multiple.tsx │ ├── range.tsx │ ├── slider.tsx │ └── vertical.tsx └── index.md ├── index.js ├── jest.config.js ├── now.json ├── package.json ├── script └── update-content.js ├── src ├── Handles │ ├── Handle.tsx │ └── index.tsx ├── Marks │ ├── Mark.tsx │ └── index.tsx ├── Slider.tsx ├── Steps │ ├── Dot.tsx │ └── index.tsx ├── Tracks │ ├── Track.tsx │ └── index.tsx ├── context.ts ├── hooks │ ├── useDrag.ts │ ├── useOffset.ts │ └── useRange.ts ├── index.tsx ├── interface.ts └── util.ts ├── tests ├── Range.test.tsx ├── Slider.test.js ├── Tooltip.test.js ├── __mocks__ │ └── rc-trigger.js ├── __snapshots__ │ ├── Range.test.tsx.snap │ └── Slider.test.js.snap ├── common.test.js ├── marks.test.js └── setup.js ├── tsconfig.json └── typings.d.ts /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | alias: { 6 | 'rc-slider$': path.resolve('src'), 7 | 'rc-slider/es': path.resolve('src'), 8 | }, 9 | mfsu: false, 10 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], 11 | themeConfig: { 12 | name: 'Slider', 13 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 14 | }, 15 | styles: [``], 16 | }); 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const base = require('@umijs/fabric/dist/eslint'); 2 | 3 | module.exports = { 4 | ...base, 5 | rules: { 6 | ...base.rules, 7 | 'react/no-array-index-key': 0, 8 | 'react/sort-comp': 0, 9 | '@typescript-eslint/no-explicit-any': 1, 10 | '@typescript-eslint/no-empty-interface': 1, 11 | '@typescript-eslint/no-inferrable-types': 0, 12 | 'react/no-find-dom-node': 1, 13 | 'react/require-default-props': 0, 14 | 'no-confusing-arrow': 0, 15 | 'import/no-named-as-default-member': 0, 16 | 'import/no-extraneous-dependencies': 0, 17 | 'jsx-a11y/label-has-for': 0, 18 | 'jsx-a11y/label-has-associated-control': 0, 19 | }, 20 | }; -------------------------------------------------------------------------------- /.fatherrc.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ant-design # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: ant-design # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: "@types/react" 11 | versions: 12 | - 17.0.0 13 | - 17.0.1 14 | - 17.0.2 15 | - 17.0.3 16 | - dependency-name: "@types/react-dom" 17 | versions: 18 | - 17.0.0 19 | - 17.0.1 20 | - 17.0.2 21 | - dependency-name: less 22 | versions: 23 | - 4.1.0 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "17 10 * * 1" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.log 3 | *.log.* 4 | .idea/ 5 | .ipr 6 | .iws 7 | *~ 8 | ~* 9 | *.diff 10 | *.patch 11 | *.bak 12 | .DS_Store 13 | Thumbs.db 14 | .project 15 | .*proj 16 | .svn/ 17 | *.swp 18 | *.swo 19 | *.pyc 20 | *.pyo 21 | .build 22 | node_modules 23 | .cache 24 | dist 25 | assets/**/*.css 26 | build 27 | lib 28 | es 29 | /coverage 30 | yarn.lock 31 | package-lock.json 32 | .doc 33 | .storybook 34 | 35 | # umi 36 | .umi 37 | .umi-production 38 | .umi-test 39 | .env.local 40 | .dumi/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "proseWrap": "never", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 9.7.1 4 | 5 | `2020-12-15` 6 | 7 | - feat: add dragableTrack. [#722](https://github.com/react-component/slider/pull/722) 8 | 9 | ## 9.6.5 10 | 11 | `2020-12-01` 12 | 13 | - chore: export slider、range and handle props interface. [#718](https://github.com/react-component/slider/pull/718) 14 | 15 | ## 9.6.4 16 | 17 | `2020-11-21` 18 | 19 | - fix: slider cannot drag to max value. [#714](https://github.com/react-component/slider/pull/714) 20 | 21 | ## 9.6.3 22 | 23 | `2020-11-17` 24 | 25 | - fix: forcePopupAlign null. [930ad6d](https://github.com/react-component/slider/commit/930ad6d117850505775956f26e025487073615dc) [69dc592](https://github.com/react-component/slider/commit/69dc59270ca46ae2d3c4b5aa073d2bc75dfc5b16) 26 | 27 | ## 9.6.2 28 | 29 | `2020-11-10` 30 | 31 | - fix: extra onChange when value out of range. [#711](https://github.com/react-component/slider/pull/711) 32 | 33 | ## 9.6.1 34 | 35 | `2020-10-31` 36 | 37 | - fix: update getLowerBound and getUpperBound to check startPoint prop. [#683](https://github.com/react-component/slider/pull/683) 38 | 39 | ## 9.6.0 40 | 41 | `2020-10-30` 42 | 43 | - fix: keep tooltip align with handle when dragging. [#696](https://github.com/react-component/slider/pull/696) 44 | 45 | --- 46 | 47 | Middle check in [releases](https://github.com/react-component/slider/releases). 48 | 49 | --- 50 | 51 | ## 9.2.0 52 | 53 | [Feature] createSliderWithTooltip support getTooltipContainer 54 | 55 | ## 9.1.0 56 | 57 | [Feature] Support `startPoint` prop. 58 | 59 | ## 8.7.0 60 | 61 | [Feature] Supprot `reverse` prop. 62 | 63 | ## 8.6.0 64 | 65 | [Feature] Allow tabIndex to be set explicitly on Handle. [#381](https://github.com/react-component/slider/pull/381) 66 | 67 | ## 8.5.0 68 | 69 | [Feature] Add focus() blur() and autoFocus. 70 | 71 | ## 8.4.0 / 2017-11-09 72 | 73 | Support React 16. 74 | 75 | ## 8.3.0 / 2017-07-28 76 | 77 | [Feature] Support keyboard accessibility.[#282](https://github.com/react-component/slider/pull/282) 78 | 79 | ## 8.2.0 / 2017-07-04 80 | 81 | [Feature] Support custom dot style with `dotStyle` & `activeDotStyle` api.[#292](https://github.com/react-component/slider/pull/292) 82 | 83 | ## 8.1.0 / 2017-06-09 84 | 85 | [Feature] rc-slider support custom style. [#281](https://github.com/react-component/slider/pull/281) 86 | 87 | ## 8.0.0 / 2017-05-31 88 | 89 | [Feature] rc-slider support aria. [#260](https://github.com/react-component/slider/pull/260/) 90 | 91 | ## 6.0.0 / 2017-01-25 92 | 93 | [Breaking Change] Re-design and refactor, almost a new UI component. 94 | 95 | ## 5.0.0 / 2016-09-12 96 | 97 | [#147](https://github.com/react-component/slider/issues/147) fix style conflicts with rc-tooltip [@benjycui](https://github.com/benjycui) 98 | [#145](https://github.com/react-component/slider/pull/145) fix `onChange` will be triggered while mousemove [@Fuzzyma](https://github.com/Fuzzyma) 99 | 100 | ## 4.0.0 / 2016-08-12 101 | 102 | [#133](https://github.com/react-component/slider/pull/133) support multi-range ([@sosz](https://github.com/sosz)) 103 | 104 | ## 3.6.0 / 2016-04-01 105 | 106 | [#18](https://github.com/react-component/slider/issues/18) add `vertical` props ([@wnlee](https://github.com/WNLee)) 107 | 108 | ... 109 | 110 | ## 1.2.5 / 2015-07-13 111 | 112 | [#8](https://github.com/react-component/slider/issues/8) add `isIncluded` props ([@simaQ](https://github.com/simaQ)) 113 | 114 | [#7](https://github.com/react-component/slider/issues/7) add tooltip for handler when slider has no `marks` props ([@simaQ](https://github.com/simaQ)) 115 | -------------------------------------------------------------------------------- /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 | # rc-slider 2 | 3 | Slider UI component for React 4 | 5 | [![NPM version][npm-image]][npm-url] 6 | [![npm download][download-image]][download-url] 7 | [![build status][github-actions-image]][github-actions-url] 8 | [![Codecov][codecov-image]][codecov-url] 9 | [![bundle size][bundlephobia-image]][bundlephobia-url] 10 | [![dumi][dumi-image]][dumi-url] 11 | 12 | [npm-image]: http://img.shields.io/npm/v/rc-slider.svg?style=flat-square 13 | [npm-url]: http://npmjs.org/package/rc-slider 14 | [travis-image]: https://img.shields.io/travis/react-component/slider/master?style=flat-square 15 | [travis-url]: https://travis-ci.com/react-component/slider 16 | [github-actions-image]: https://github.com/react-component/slider/workflows/CI/badge.svg 17 | [github-actions-url]: https://github.com/react-component/slider/actions 18 | [codecov-image]: https://img.shields.io/codecov/c/github/react-component/slider/master.svg?style=flat-square 19 | [codecov-url]: https://app.codecov.io/gh/react-component/slider 20 | [david-url]: https://david-dm.org/react-component/slider 21 | [david-image]: https://david-dm.org/react-component/slider/status.svg?style=flat-square 22 | [david-dev-url]: https://david-dm.org/react-component/slider?type=dev 23 | [david-dev-image]: https://david-dm.org/react-component/slider/dev-status.svg?style=flat-square 24 | [download-image]: https://img.shields.io/npm/dm/rc-slider.svg?style=flat-square 25 | [download-url]: https://npmjs.org/package/rc-slider 26 | [bundlephobia-url]: https://bundlephobia.com/package/rc-slider 27 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-slider 28 | [dumi-url]: https://github.com/umijs/dumi 29 | [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square 30 | ## Install 31 | 32 | [![rc-slider](https://nodei.co/npm/rc-slider.png)](https://npmjs.org/package/rc-slider) 33 | 34 | ## Example 35 | 36 | `npm start` and then go to http://localhost:8000 37 | 38 | Online examples: https://slider.react-component.now.sh/ 39 | 40 | ## Usage 41 | 42 | ## Slider 43 | ```js 44 | import Slider from 'rc-slider'; 45 | import 'rc-slider/assets/index.css'; 46 | 47 | export default () => ( 48 | <> 49 | 50 | 51 | ); 52 | ``` 53 | 54 | ## Range 55 | Please refer to [#825](https://github.com/react-component/slider/issues/825) for information regarding usage of `Range`. 56 | An example: 57 | ```js 58 | import Slider, { Range } from 'rc-slider'; 59 | import 'rc-slider/assets/index.css'; 60 | 61 | export default () => ( 62 | <> 63 | 64 | 65 | ); 66 | ``` 67 | 68 | ## Compatibility 69 | 70 | | [IE / Edge](http://godban.github.io/browsers-support-badges/)
IE / Edge | [Firefox](http://godban.github.io/browsers-support-badges/)
Firefox | [Chrome](http://godban.github.io/browsers-support-badges/)
Chrome | [Safari](http://godban.github.io/browsers-support-badges/)
Safari | [Electron](http://godban.github.io/browsers-support-badges/)
Electron | 71 | | --- | --- | --- | --- | --- | 72 | | IE11, Edge | last 2 versions | last 2 versions | last 2 versions | last 2 versions | 73 | 74 | ## API 75 | 76 | ### createSliderWithTooltip(Slider | Range) => React.Component 77 | 78 | An extension to make Slider or Range support Tooltip on handle. 79 | 80 | ```js 81 | const Slider = require('rc-slider'); 82 | const createSliderWithTooltip = Slider.createSliderWithTooltip; 83 | const Range = createSliderWithTooltip(Slider.Range); 84 | ``` 85 | 86 | [Online demo](http://react-component.github.io/slider/?path=/story/rc-slider--handle) 87 | 88 | After Range or Slider was wrapped by createSliderWithTooltip, it will have the following props: 89 | 90 | | Name | Type | Default | Description | 91 | | ------------ | ------- | ------- | ----------- | 92 | | tipFormatter | (value: number): React.ReactNode | `value => value` | A function to format tooltip's overlay | 93 | | tipProps | Object | `{`
`placement: 'top',`
` prefixCls: 'rc-slider-tooltip',`
`overlay: tipFormatter(value)`
`}` | A function to format tooltip's overlay | 94 | 95 | ### Common API 96 | 97 | The following APIs are shared by Slider and Range. 98 | 99 | | Name | Type | Default | Description | 100 | | ------------ | ------- | ------- | ----------- | 101 | | className | string | `''` | Additional CSS class for the root DOM node | 102 | | min | number | `0` | The minimum value of the slider | 103 | | max | number | `100` | The maximum value of the slider | 104 | | id | string | `''` | Unique identifier for the component, used for accessibility | 105 | | marks | `{number: ReactNode}` or`{number: { style, label }}` | `{}` | Marks on the slider. The key determines the position, and the value determines what will show. If you want to set the style of a specific mark point, the value should be an object which contains `style` and `label` properties. | 106 | | step | number or `null` | `1` | Value to be added or subtracted on each step the slider makes. Must be greater than zero, and `max` - `min` should be evenly divisible by the step value.
When `marks` is not an empty object, `step` can be set to `null`, to make `marks` as steps. | 107 | | vertical | boolean | `false` | If vertical is `true`, the slider will be vertical. | 108 | | handle | (props) => React.ReactNode | | A handle generator which could be used to customized handle. | 109 | | included | boolean | `true` | If the value is `true`, it means a continuous value interval, otherwise, it is a independent value. | 110 | | reverse | boolean | `false` | If the value is `true`, it means the component is rendered reverse. | 111 | | disabled | boolean | `false` | If `true`, handles can't be moved. | 112 | | keyboard | boolean | `true` | Support using keyboard to move handlers. | 113 | | dots | boolean | `false` | When the `step` value is greater than 1, you can set the `dots` to `true` if you want to render the slider with dots. | 114 | | onBeforeChange | Function | NOOP | `onBeforeChange` will be triggered when `ontouchstart` or `onmousedown` is triggered. | 115 | | onChange | Function | NOOP | `onChange` will be triggered while the value of Slider changing. | 116 | | onChangeComplete | Function | NOOP | `onChangeComplete` will be triggered when `ontouchend` or `onmouseup` is triggered. | 117 | | minimumTrackStyle | Object | | please use `trackStyle` instead. (`only used for slider, just for compatibility , will be deprecate at rc-slider@9.x `) | 118 | | maximumTrackStyle | Object | | please use `railStyle` instead (`only used for slider, just for compatibility , will be deprecate at rc-slider@9.x`) | 119 | | handleStyle | Array[Object] \| Object | `[{}]` | The style used for handle. (`both for slider(`Object`) and range(`Array of Object`), the array will be used for multi handle following element order`) | 120 | | trackStyle | Array[Object] \| Object | `[{}]` | The style used for track. (`both for slider(`Object`) and range(`Array of Object`), the array will be used for multi track following element order`)| 121 | | railStyle | Object | `{}` | The style used for the track base color. | 122 | | dotStyle | Object \| (dotValue) => Object | `{}` | The style used for the dots. | 123 | | activeDotStyle | Object \| (dotValue) => Object | `{}` | The style used for the active dots. | 124 | 125 | ### Slider 126 | 127 | | Name | Type | Default | Description | 128 | | ------------ | ------- | ------- | ----------- | 129 | | defaultValue | number | `0` | Set initial value of slider. | 130 | | value | number | - | Set current value of slider. | 131 | | startPoint | number | `undefined` | Track starts from this value. If `undefined`, `min` is used. | 132 | | tabIndex | number | `0` | Set the tabIndex of the slider handle. | 133 | | ariaLabelForHandle | string | - | Set the `aria-label` attribute on the slider handle. | 134 | | ariaLabelledByForHandle | string | - | Set the `aria-labelledby` attribute on the slider handle. | 135 | | ariaRequired | boolean | - | Set the `aria-required` attribute on the slider handle. | 136 | | ariaValueTextFormatterForHandle | (value) => string | - | A function to set the `aria-valuetext` attribute on the slider handle. It receives the current value of the slider and returns a formatted string describing the value. See [WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/#slider) for more information. | 137 | 138 | ### Range 139 | 140 | | Name | Type | Default | Description | 141 | | ------------ | ------- | ------- | ----------- | 142 | | defaultValue | `number[]` | `[0, 0]` | Set initial positions of handles. | 143 | | value | `number[]` | | Set current positions of handles. | 144 | | tabIndex | number[] | `[0, 0]` | Set the tabIndex of each handle. | 145 | | ariaLabelGroupForHandles | Array[string] | - | Set the `aria-label` attribute on each handle. | 146 | | ariaLabelledByGroupForHandles | Array[string] | - | Set the `aria-labelledby` attribute on each handle. | 147 | | ariaValueTextFormatterGroupForHandles | Array[(value) => string] | - | A function to set the `aria-valuetext` attribute on each handle. It receives the current value of the slider and returns a formatted string describing the value. See [WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/#slider) for more information. | 148 | | count | number | `1` | Determine how many ranges to render, and multiple handles will be rendered (number + 1). | 149 | | allowCross | boolean | `true` | `allowCross` could be set as `true` to allow those handles to cross. | 150 | | pushable | boolean or number | `false` | `pushable` could be set as `true` to allow pushing of surrounding handles when moving a handle. When set to a number, the number will be the minimum ensured distance between handles. Example: ![](http://i.giphy.com/l46Cs36c9HrHMExoc.gif) | 151 | | draggableTrack | boolean | `false` | Open the track drag. open after click on the track will be invalid. | 152 | 153 | ### SliderTooltip 154 | 155 | The Tooltip Component that keep following with content. 156 | 157 | ## Development 158 | 159 | ``` 160 | npm install 161 | npm start 162 | ``` 163 | 164 | ## Test Case 165 | 166 | `npm run test` 167 | 168 | ## Coverage 169 | 170 | `npm run coverage` 171 | ## License 172 | 173 | `rc-slider` is released under the MIT license. 174 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @prefixClass: rc-slider; 2 | 3 | @disabledColor: #ccc; 4 | @border-radius-base: 6px; 5 | @primary-color: #2db7f5; 6 | @tooltip-color: #fff; 7 | @tooltip-bg: tint(#666, 4%); 8 | @tooltip-arrow-width: 4px; 9 | @tooltip-distance: @tooltip-arrow-width+4; 10 | @tooltip-arrow-color: @tooltip-bg; 11 | @ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1); 12 | @ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06); 13 | 14 | .borderBox() { 15 | box-sizing: border-box; 16 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); // remove tap highlight color for mobile safari 17 | 18 | * { 19 | box-sizing: border-box; 20 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); // remove tap highlight color for mobile safari 21 | } 22 | } 23 | 24 | .@{prefixClass} { 25 | position: relative; 26 | width: 100%; 27 | height: 14px; 28 | padding: 5px 0; 29 | border-radius: @border-radius-base; 30 | touch-action: none; 31 | .borderBox(); 32 | 33 | &-rail { 34 | position: absolute; 35 | width: 100%; 36 | height: 4px; 37 | background-color: #e9e9e9; 38 | border-radius: @border-radius-base; 39 | } 40 | 41 | &-track, 42 | &-tracks { 43 | position: absolute; 44 | height: 4px; 45 | background-color: tint(@primary-color, 60%); 46 | border-radius: @border-radius-base; 47 | } 48 | 49 | &-track-draggable { 50 | z-index: 1; 51 | box-sizing: content-box; 52 | background-clip: content-box; 53 | border-top: 5px solid rgba(0, 0, 0, 0); 54 | border-bottom: 5px solid rgba(0, 0, 0, 0); 55 | transform: translateY(-5px); 56 | } 57 | 58 | &-handle { 59 | position: absolute; 60 | z-index: 1; 61 | width: 14px; 62 | height: 14px; 63 | margin-top: -5px; 64 | background-color: #fff; 65 | border: solid 2px tint(@primary-color, 50%); 66 | border-radius: 50%; 67 | cursor: pointer; 68 | cursor: -webkit-grab; 69 | cursor: grab; 70 | opacity: 0.8; 71 | user-select: none; 72 | touch-action: pan-x; 73 | 74 | &-dragging&-dragging&-dragging { 75 | border-color: tint(@primary-color, 20%); 76 | box-shadow: 0 0 0 5px tint(@primary-color, 50%); 77 | 78 | &-delete { 79 | opacity: 0; 80 | } 81 | } 82 | 83 | &:focus { 84 | outline: none; 85 | box-shadow: none; 86 | } 87 | 88 | &:focus-visible { 89 | border-color: @primary-color; 90 | box-shadow: 0 0 0 3px tint(@primary-color, 50%); 91 | } 92 | 93 | &-click-focused:focus { 94 | border-color: tint(@primary-color, 50%); 95 | box-shadow: unset; 96 | } 97 | 98 | &:hover { 99 | border-color: tint(@primary-color, 20%); 100 | } 101 | 102 | &:active { 103 | border-color: tint(@primary-color, 20%); 104 | box-shadow: 0 0 5px tint(@primary-color, 20%); 105 | cursor: -webkit-grabbing; 106 | cursor: grabbing; 107 | } 108 | } 109 | 110 | &-mark { 111 | position: absolute; 112 | top: 18px; 113 | left: 0; 114 | width: 100%; 115 | font-size: 12px; 116 | } 117 | 118 | &-mark-text { 119 | position: absolute; 120 | display: inline-block; 121 | color: #999; 122 | text-align: center; 123 | vertical-align: middle; 124 | cursor: pointer; 125 | 126 | &-active { 127 | color: #666; 128 | } 129 | } 130 | 131 | &-step { 132 | position: absolute; 133 | width: 100%; 134 | height: 4px; 135 | background: transparent; 136 | } 137 | 138 | &-dot { 139 | position: absolute; 140 | bottom: -2px; 141 | width: 8px; 142 | height: 8px; 143 | vertical-align: middle; 144 | background-color: #fff; 145 | border: 2px solid #e9e9e9; 146 | border-radius: 50%; 147 | cursor: pointer; 148 | &-active { 149 | border-color: tint(@primary-color, 50%); 150 | } 151 | &-reverse { 152 | margin-right: -4px; 153 | } 154 | } 155 | 156 | &-disabled { 157 | background-color: #e9e9e9; 158 | 159 | .@{prefixClass}-track { 160 | background-color: @disabledColor; 161 | } 162 | 163 | .@{prefixClass}-handle, 164 | .@{prefixClass}-dot { 165 | background-color: #fff; 166 | border-color: @disabledColor; 167 | box-shadow: none; 168 | cursor: not-allowed; 169 | } 170 | 171 | .@{prefixClass}-mark-text, 172 | .@{prefixClass}-dot { 173 | cursor: not-allowed !important; 174 | } 175 | } 176 | } 177 | 178 | .@{prefixClass}-vertical { 179 | width: 14px; 180 | height: 100%; 181 | padding: 0 5px; 182 | 183 | .@{prefixClass} { 184 | &-rail { 185 | width: 4px; 186 | height: 100%; 187 | } 188 | 189 | &-track { 190 | bottom: 0; 191 | left: 5px; 192 | width: 4px; 193 | } 194 | 195 | &-track-draggable { 196 | border-top: 0; 197 | border-right: 5px solid rgba(0, 0, 0, 0); 198 | border-bottom: 0; 199 | border-left: 5px solid rgba(0, 0, 0, 0); 200 | transform: translateX(-5px); 201 | } 202 | 203 | &-handle { 204 | position: absolute; 205 | z-index: 1; 206 | margin-top: 0; 207 | margin-left: -5px; 208 | touch-action: pan-y; 209 | } 210 | 211 | &-mark { 212 | top: 0; 213 | left: 18px; 214 | height: 100%; 215 | } 216 | 217 | &-step { 218 | width: 4px; 219 | height: 100%; 220 | } 221 | 222 | &-dot { 223 | margin-left: -2px; 224 | } 225 | } 226 | } 227 | 228 | .motion-common() { 229 | display: block !important; 230 | animation-duration: 0.3s; 231 | animation-fill-mode: both; 232 | } 233 | 234 | .make-motion(@className, @keyframeName) { 235 | .@{className}-enter, 236 | .@{className}-appear { 237 | .motion-common(); 238 | animation-play-state: paused; 239 | } 240 | .@{className}-leave { 241 | .motion-common(); 242 | animation-play-state: paused; 243 | } 244 | .@{className}-enter.@{className}-enter-active, 245 | .@{className}-appear.@{className}-appear-active { 246 | animation-name: ~'@{keyframeName}In'; 247 | animation-play-state: running; 248 | } 249 | .@{className}-leave.@{className}-leave-active { 250 | animation-name: ~'@{keyframeName}Out'; 251 | animation-play-state: running; 252 | } 253 | } 254 | .zoom-motion(@className, @keyframeName) { 255 | .make-motion(@className, @keyframeName); 256 | .@{className}-enter, 257 | .@{className}-appear { 258 | transform: scale(0, 0); // need this by yiminghe 259 | animation-timing-function: @ease-out-quint; 260 | } 261 | .@{className}-leave { 262 | animation-timing-function: @ease-in-quint; 263 | } 264 | } 265 | .zoom-motion(rc-slider-tooltip-zoom-down, rcSliderTooltipZoomDown); 266 | 267 | @keyframes rcSliderTooltipZoomDownIn { 268 | 0% { 269 | transform: scale(0, 0); 270 | transform-origin: 50% 100%; 271 | opacity: 0; 272 | } 273 | 100% { 274 | transform: scale(1, 1); 275 | transform-origin: 50% 100%; 276 | } 277 | } 278 | 279 | @keyframes rcSliderTooltipZoomDownOut { 280 | 0% { 281 | transform: scale(1, 1); 282 | transform-origin: 50% 100%; 283 | } 284 | 100% { 285 | transform: scale(0, 0); 286 | transform-origin: 50% 100%; 287 | opacity: 0; 288 | } 289 | } 290 | 291 | .@{prefixClass}-tooltip { 292 | position: absolute; 293 | top: -9999px; 294 | left: -9999px; 295 | visibility: visible; 296 | 297 | .borderBox(); 298 | 299 | &-hidden { 300 | display: none; 301 | } 302 | 303 | &-placement-top { 304 | padding: @tooltip-arrow-width 0 @tooltip-distance 0; 305 | } 306 | 307 | &-inner { 308 | min-width: 24px; 309 | height: 24px; 310 | padding: 6px 2px; 311 | color: @tooltip-color; 312 | font-size: 12px; 313 | line-height: 1; 314 | text-align: center; 315 | text-decoration: none; 316 | background-color: @tooltip-bg; 317 | border-radius: @border-radius-base; 318 | box-shadow: 0 0 4px #d9d9d9; 319 | } 320 | 321 | &-arrow { 322 | position: absolute; 323 | width: 0; 324 | height: 0; 325 | border-color: transparent; 326 | border-style: solid; 327 | } 328 | 329 | &-placement-top &-arrow { 330 | bottom: @tooltip-distance - @tooltip-arrow-width; 331 | left: 50%; 332 | margin-left: -@tooltip-arrow-width; 333 | border-width: @tooltip-arrow-width @tooltip-arrow-width 0; 334 | border-top-color: @tooltip-arrow-color; 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/demo/debug.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Debug 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/editable.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Editable 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/handle.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Handle 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/marks.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Marks 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/mulitple.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Multiple 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/range.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Range 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/slider.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Slider 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/vertical.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Vertical 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/examples/components/TooltipSlider.tsx: -------------------------------------------------------------------------------- 1 | import type { SliderProps } from 'rc-slider'; 2 | import Slider from 'rc-slider'; 3 | import type { TooltipRef } from 'rc-tooltip'; 4 | import Tooltip from 'rc-tooltip'; 5 | import 'rc-tooltip/assets/bootstrap.css'; 6 | import raf from 'rc-util/lib/raf'; 7 | import * as React from 'react'; 8 | 9 | interface HandleTooltipProps { 10 | value: number; 11 | children: React.ReactElement; 12 | visible: boolean; 13 | tipFormatter?: (value: number) => React.ReactNode; 14 | } 15 | 16 | const HandleTooltip: React.FC = (props) => { 17 | const { value, children, visible, tipFormatter = (val) => `${val} %`, ...restProps } = props; 18 | 19 | const tooltipRef = React.useRef(); 20 | const rafRef = React.useRef(null); 21 | 22 | function cancelKeepAlign() { 23 | raf.cancel(rafRef.current!); 24 | } 25 | 26 | function keepAlign() { 27 | rafRef.current = raf(() => { 28 | tooltipRef.current?.forceAlign(); 29 | }); 30 | } 31 | 32 | React.useEffect(() => { 33 | if (visible) { 34 | keepAlign(); 35 | } else { 36 | cancelKeepAlign(); 37 | } 38 | 39 | return cancelKeepAlign; 40 | }, [value, visible]); 41 | 42 | return ( 43 | 51 | {children} 52 | 53 | ); 54 | }; 55 | 56 | export const handleRender: SliderProps['handleRender'] = (node, props) => ( 57 | 58 | {node} 59 | 60 | ); 61 | 62 | interface TooltipSliderProps extends SliderProps { 63 | tipFormatter?: (value: number) => React.ReactNode; 64 | tipProps?: any; 65 | } 66 | 67 | const TooltipSlider: React.FC = ({ tipFormatter, tipProps, ...props }) => { 68 | const tipHandleRender: SliderProps['handleRender'] = (node, handleProps) => ( 69 | 75 | {node} 76 | 77 | ); 78 | 79 | return ; 80 | }; 81 | 82 | export default TooltipSlider; 83 | -------------------------------------------------------------------------------- /docs/examples/debug.tsx: -------------------------------------------------------------------------------- 1 | import Slider from 'rc-slider'; 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | 5 | export default () => { 6 | const [disabled, setDisabled] = React.useState(false); 7 | const [range, setRange] = React.useState(false); 8 | const [reverse, setReverse] = React.useState(false); 9 | const [vertical, setVertical] = React.useState(false); 10 | 11 | return ( 12 |
13 |
14 | 18 | 22 | 26 | 30 |
31 | 32 |
33 | { 35 | console.log('Change:', nextValues); 36 | }} 37 | onChangeComplete={(v) => { 38 | console.log('AfterChange:', v); 39 | }} 40 | min={0} 41 | max={1} 42 | defaultValue={0.81} 43 | step={0.01} 44 | /> 45 |
46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /docs/examples/editable.tsx: -------------------------------------------------------------------------------- 1 | /* eslint react/no-multi-comp: 0, no-console: 0 */ 2 | import Slider, { UnstableContext } from 'rc-slider'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | import type { UnstableContextProps } from '../../src/context'; 6 | 7 | const style: React.CSSProperties = { 8 | width: 400, 9 | margin: 50, 10 | }; 11 | 12 | export default () => { 13 | const [value, setValue] = React.useState([0, 50, 80]); 14 | 15 | const onDragStart: UnstableContextProps['onDragStart'] = (info) => { 16 | const { rawValues } = info; 17 | console.log('Start:', rawValues); 18 | }; 19 | 20 | const onDragChange: UnstableContextProps['onDragChange'] = (info) => { 21 | const { rawValues } = info; 22 | console.log('Move:', rawValues); 23 | }; 24 | 25 | return ( 26 |
27 |
28 | 29 | { 42 | console.error('Change:', nextValue); 43 | setValue(nextValue as any); 44 | }} 45 | onChangeComplete={(nextValue) => { 46 | console.log('Complete', nextValue); 47 | }} 48 | // handleRender={(ori, handleProps) => { 49 | // if (handleProps.index === 0) { 50 | // console.log('handleRender', ori, handleProps); 51 | // } 52 | // return ori; 53 | // }} 54 | styles={{ 55 | rail: { 56 | background: `linear-gradient(to right, blue, red)`, 57 | }, 58 | track: { 59 | background: 'orange', 60 | }, 61 | }} 62 | /> 63 | 64 |
65 | 66 |

Here is a word that drag should not select it

67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /docs/examples/handle.tsx: -------------------------------------------------------------------------------- 1 | import Slider from 'rc-slider'; 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import TooltipSlider, { handleRender } from './components/TooltipSlider'; 5 | 6 | const wrapperStyle: React.CSSProperties = { 7 | width: 400, 8 | margin: 50, 9 | }; 10 | 11 | export default () => ( 12 |
13 |
14 |

Slider with custom handle

15 | 16 |
17 |
18 |

Reversed Slider with custom handle

19 | 20 |
21 |
22 |

Slider with fixed values

23 | 24 |
25 |
26 |

Range with custom tooltip

27 | `${value}!`} 33 | /> 34 |
35 |
36 |

Keyboard events disabled

37 | 38 |
39 |
40 | ); 41 | -------------------------------------------------------------------------------- /docs/examples/marks.tsx: -------------------------------------------------------------------------------- 1 | import Slider from 'rc-slider'; 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | 5 | const style: React.CSSProperties = { 6 | width: 400, 7 | margin: 50, 8 | }; 9 | 10 | const marks = { 11 | '-10': '-10°C', 12 | 0: 0°C, 13 | 26: '26°C', 14 | 37: '37°C', 15 | 50: '50°C', 16 | 100: { 17 | style: { 18 | color: 'red', 19 | }, 20 | label: 100°C, 21 | }, 22 | }; 23 | 24 | function log(value) { 25 | console.log(value); //eslint-disable-line 26 | } 27 | 28 | export default () => ( 29 |
30 |
31 |

Slider with marks, `step=null`

32 | console.log('AfterChange:', v)} 39 | /> 40 |
41 | 42 |
43 |

Range Slider with marks, `step=null`, pushable, draggableTrack

44 | console.log('AfterChange:', v)} 54 | /> 55 |
56 | 57 |
58 |

Slider with marks and steps

59 | 60 |
61 |
62 |

Reversed Slider with marks and steps

63 | 64 |
65 | 66 |
67 |

Slider with marks, `included=false`

68 | 69 |
70 |
71 |

Slider with marks and steps, `included=false`

72 | 73 |
74 | 75 |
76 |

Range with marks

77 | 78 |
79 |
80 |

Range with marks and steps

81 | 82 |
83 |
84 | ); 85 | -------------------------------------------------------------------------------- /docs/examples/multiple.tsx: -------------------------------------------------------------------------------- 1 | /* eslint react/no-multi-comp: 0, no-console: 0 */ 2 | import Slider from 'rc-slider'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | const style: React.CSSProperties = { 7 | width: 400, 8 | margin: 50, 9 | }; 10 | 11 | function log(value) { 12 | console.log(value); 13 | } 14 | 15 | const NodeWrapper = ({ children }: { children: React.ReactElement }) => { 16 | return
{React.cloneElement(children, {},
TOOLTIP
)}
; 17 | }; 18 | 19 | export default () => { 20 | const [value, setValue] = React.useState([0, 50, 80]); 21 | 22 | return ( 23 |
24 |
25 | { 33 | // console.log('>>>', nextValue); 34 | // setValue(nextValue as any); 35 | }} 36 | activeHandleRender={(node) => {node}} 37 | styles={{ 38 | tracks: { 39 | background: `linear-gradient(to right, blue, red)`, 40 | }, 41 | track: { 42 | background: 'transparent', 43 | }, 44 | }} 45 | /> 46 |
47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /docs/examples/range.tsx: -------------------------------------------------------------------------------- 1 | /* eslint react/no-multi-comp: 0, no-console: 0 */ 2 | import Slider from 'rc-slider'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | 6 | const style: React.CSSProperties = { 7 | width: 400, 8 | margin: 50, 9 | }; 10 | 11 | function log(value) { 12 | console.log(value); //eslint-disable-line 13 | } 14 | 15 | class CustomizedRange extends React.Component { 16 | constructor(props) { 17 | super(props); 18 | this.state = { 19 | lowerBound: 20, 20 | upperBound: 40, 21 | value: [20, 40], 22 | }; 23 | } 24 | 25 | onLowerBoundChange = (e) => { 26 | this.setState({ lowerBound: +e.target.value }); 27 | }; 28 | 29 | onUpperBoundChange = (e) => { 30 | this.setState({ upperBound: +e.target.value }); 31 | }; 32 | 33 | onSliderChange = (value) => { 34 | log(value); 35 | this.setState({ 36 | value, 37 | }); 38 | }; 39 | 40 | handleApply = () => { 41 | const { lowerBound, upperBound } = this.state; 42 | this.setState({ value: [lowerBound, upperBound] }); 43 | }; 44 | 45 | render() { 46 | return ( 47 |
48 | 49 | 50 |
51 | 52 | 53 |
54 | 57 |
58 |
59 | 60 |
61 | ); 62 | } 63 | } 64 | 65 | class DynamicBounds extends React.Component { 66 | constructor(props) { 67 | super(props); 68 | this.state = { 69 | min: 0, 70 | max: 100, 71 | }; 72 | } 73 | 74 | onSliderChange = (value) => { 75 | log(value); 76 | }; 77 | 78 | onMinChange = (e) => { 79 | this.setState({ 80 | min: +e.target.value || 0, 81 | }); 82 | }; 83 | 84 | onMaxChange = (e) => { 85 | this.setState({ 86 | max: +e.target.value || 100, 87 | }); 88 | }; 89 | 90 | render() { 91 | return ( 92 |
93 | 94 | 95 |
96 | 97 | 98 |
99 |
100 | 107 |
108 | ); 109 | } 110 | } 111 | 112 | class ControlledRange extends React.Component { 113 | constructor(props) { 114 | super(props); 115 | this.state = { 116 | value: [20, 40, 60, 80], 117 | }; 118 | } 119 | 120 | handleChange = (value) => { 121 | this.setState({ 122 | value, 123 | }); 124 | }; 125 | 126 | render() { 127 | return ; 128 | } 129 | } 130 | 131 | class ControlledRangeDisableAcross extends React.Component { 132 | constructor(props) { 133 | super(props); 134 | this.state = { 135 | value: [20, 40, 60, 80], 136 | }; 137 | } 138 | 139 | handleChange = (value) => { 140 | this.setState({ 141 | value, 142 | }); 143 | }; 144 | 145 | render() { 146 | return ( 147 | 154 | ); 155 | } 156 | } 157 | 158 | // https://github.com/react-component/slider/issues/226 159 | class PureRenderRange extends React.Component { 160 | constructor(props) { 161 | super(props); 162 | this.state = { 163 | foo: false, 164 | }; 165 | } 166 | 167 | handleChange = (value) => { 168 | console.log(value); 169 | this.setState(({ foo }) => ({ foo: !foo })); 170 | }; 171 | 172 | render() { 173 | return ( 174 | 180 | ); 181 | } 182 | } 183 | 184 | export default () => ( 185 |
186 |
187 |

Basic Range,`allowCross=false`

188 | 189 |
190 |
191 |

Basic reverse Range`

192 | 193 |
194 |
195 |

Basic Range,`step=20`

196 | 197 |
198 |
199 |

Basic Range,`step=20, dots`

200 | 201 |
202 |
203 |

Basic Range,disabled

204 | 205 |
206 |
207 |

Controlled Range

208 | 209 |
210 |
211 |

Controlled Range, not allow across

212 | 213 |
214 |
215 |

Controlled Range, not allow across, pushable=5

216 | 217 |
218 |
219 |

Multi Range, count=3 and pushable=true

220 | 221 |
222 |
223 |

Multi Range with custom track and handle style and pushable

224 | 233 |
234 |
235 |

Customized Range

236 | 237 |
238 |
239 |

Range with dynamic `max` `min`

240 | 241 |
242 |
243 |

Range as child component

244 | 245 |
246 |
247 |

draggableTrack two points

248 | 249 |
250 |
251 |

draggableTrack two points(reverse)

252 | 259 |
260 |
261 |

draggableTrack multiple points

262 | 268 |
269 |
270 | ); 271 | -------------------------------------------------------------------------------- /docs/examples/slider.tsx: -------------------------------------------------------------------------------- 1 | /* eslint react/no-multi-comp: 0, max-len: 0 */ 2 | import Slider from 'rc-slider'; 3 | import React from 'react'; 4 | import '../../assets/index.less'; 5 | import TooltipSlider from './components/TooltipSlider'; 6 | 7 | const style: React.CSSProperties = { 8 | width: 600, 9 | margin: 50, 10 | }; 11 | 12 | function log(value) { 13 | console.log(value); //eslint-disable-line 14 | } 15 | 16 | function percentFormatter(v) { 17 | return `${v} %`; 18 | } 19 | 20 | // const SliderWithTooltip = createSliderWithTooltip(Slider); 21 | 22 | class NullableSlider extends React.Component { 23 | constructor(props) { 24 | super(props); 25 | this.state = { 26 | value: null, 27 | }; 28 | } 29 | 30 | onSliderChange = (value) => { 31 | log(value); 32 | this.setState({ 33 | value, 34 | }); 35 | }; 36 | 37 | onAfterChange = (value) => { 38 | console.log(value); //eslint-disable-line 39 | }; 40 | 41 | reset = () => { 42 | console.log('reset value'); // eslint-disable-line 43 | this.setState({ value: null }); 44 | }; 45 | 46 | render() { 47 | return ( 48 |
49 | 54 | 57 |
58 | ); 59 | } 60 | } 61 | 62 | const NullableRangeSlider = () => { 63 | const [value, setValue] = React.useState(null); 64 | 65 | return ( 66 |
67 | 68 | 71 |
72 | ); 73 | }; 74 | 75 | class CustomizedSlider extends React.Component { 76 | constructor(props) { 77 | super(props); 78 | this.state = { 79 | value: 50, 80 | }; 81 | } 82 | 83 | onSliderChange = (value) => { 84 | log(value); 85 | this.setState({ 86 | value, 87 | }); 88 | }; 89 | 90 | onAfterChange = (value) => { 91 | console.log(value); //eslint-disable-line 92 | }; 93 | 94 | render() { 95 | return ( 96 | 101 | ); 102 | } 103 | } 104 | 105 | class DynamicBounds extends React.Component { 106 | constructor(props) { 107 | super(props); 108 | this.state = { 109 | min: 1, 110 | max: 100, 111 | step: 10, 112 | value: 1, 113 | }; 114 | } 115 | 116 | onSliderChange = (value) => { 117 | log(value); 118 | this.setState({ value }); 119 | }; 120 | 121 | onMinChange = (e) => { 122 | this.setState({ 123 | min: +e.target.value || 0, 124 | }); 125 | }; 126 | 127 | onMaxChange = (e) => { 128 | this.setState({ 129 | max: +e.target.value || 100, 130 | }); 131 | }; 132 | 133 | onStepChange = (e) => { 134 | this.setState({ 135 | step: +e.target.value || 1, 136 | }); 137 | }; 138 | 139 | render() { 140 | const labelStyle = { minWidth: '60px', display: 'inline-block' }; 141 | const inputStyle = { marginBottom: '10px' }; 142 | return ( 143 |
144 | 145 | 151 |
152 | 153 | 159 |
160 | 161 | 167 |
168 |
169 | 170 | {this.state.value} 171 |
172 |
173 | 180 |
181 | ); 182 | } 183 | } 184 | 185 | export default () => ( 186 |
187 |
188 |

Basic Slider

189 | 190 |
191 |
192 |

Basic Slider, `startPoint=50`

193 | 194 |
195 |
196 |

Slider reverse

197 | 198 |
199 |
200 |

Basic Slider,`step=20`

201 | 202 |
203 |
204 |

Basic Slider,`step=20, dots`

205 | 206 |
207 |
208 |

209 | Basic Slider,`step=20, dots, dotStyle={"{borderColor: 'orange'}"}, activeDotStyle= 210 | {"{borderColor: 'yellow'}"}` 211 |

212 | 220 |
221 |
222 |

Slider with tooltip, with custom `tipFormatter`

223 | 228 |
229 |
230 |

231 | Slider with custom handle and track style.(old api, will be deprecated) 232 |

233 | 246 |
247 |
248 |

249 | Slider with custom handle and track style.(The recommended new api) 250 |

251 | 264 |
265 |
266 |

267 | Reversed Slider with custom handle and track style. 268 | (The recommended new api) 269 |

270 | 284 |
285 |
286 |

Basic Slider, disabled

287 | 288 |
289 |
290 |

Controlled Slider

291 | 292 |
293 |
294 |

Customized Slider

295 | 296 |
297 |
298 |

Slider with null value and reset button

299 | 300 |
301 |
302 |

Range Slider with null value and reset button

303 | 304 |
305 |
306 |

Slider with dynamic `min` `max` `step`

307 | 308 |
309 |
310 | ); 311 | -------------------------------------------------------------------------------- /docs/examples/vertical.tsx: -------------------------------------------------------------------------------- 1 | import Slider from 'rc-slider'; 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | 5 | const style: React.CSSProperties = { 6 | float: 'left', 7 | width: 160, 8 | height: 400, 9 | marginBottom: 160, 10 | marginLeft: 50, 11 | }; 12 | 13 | const parentStyle: React.CSSProperties = { 14 | overflow: 'hidden', 15 | }; 16 | 17 | const marks = { 18 | '-10': '-10°C', 19 | 0: 0°C, 20 | 26: '26°C', 21 | 37: '37°C', 22 | 50: '50°C', 23 | 100: { 24 | style: { 25 | color: 'red', 26 | }, 27 | label: 100°C, 28 | }, 29 | }; 30 | 31 | function log(value) { 32 | console.log(value); //eslint-disable-line 33 | } 34 | 35 | export default () => ( 36 |
37 |
38 |

Slider with marks, `step=null`

39 | 40 |
41 |
42 |

Slider with marks, `step=null` and `startPoint=0`

43 | 52 |
53 |
54 |

Reverse Slider with marks, `step=null`

55 | 64 |
65 |
66 |

Slider with marks and steps

67 | 68 |
69 |
70 |

Slider with marks, `included=false`

71 | 72 |
73 |
74 |

Slider with marks and steps, `included=false`

75 | 76 |
77 |
78 |

Range with marks

79 | 80 |
81 |
82 |

Range with marks and steps

83 | 92 |
93 |
94 |

Range with marks and draggableTrack

95 | 103 |
104 |
105 |

Range with marks and draggableTrack(reverse)

106 | 115 |
116 |
117 | ); 118 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: rc-slider 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/'); 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ["./tests/setup.js"], 3 | }; 4 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-slider", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": "dist" } 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rc-slider", 3 | "version": "11.1.8", 4 | "description": "Slider UI component for React", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-slider", 9 | "slider", 10 | "input", 11 | "range" 12 | ], 13 | "homepage": "http://github.com/react-component/slider/", 14 | "bugs": { 15 | "url": "http://github.com/react-component/slider/issues" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git@github.com:react-component/slider.git" 20 | }, 21 | "license": "MIT", 22 | "main": "./lib/index", 23 | "module": "./es/index", 24 | "types": "./lib/index.d.ts", 25 | "style": "./assets/index.css", 26 | "files": [ 27 | "assets/*.css", 28 | "lib", 29 | "es" 30 | ], 31 | "scripts": { 32 | "compile": "father build && lessc assets/index.less assets/index.css", 33 | "coverage": "rc-test --coverage", 34 | "docs:build": "dumi build", 35 | "docs:deploy": "gh-pages -d .doc", 36 | "lint": "eslint src/ --ext .ts,.tsx,.jsx,.js,.md", 37 | "now-build": "npm run docs:build", 38 | "prepublishOnly": "npm run compile && np --yolo --no-publish", 39 | "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", 40 | "start": "dumi dev", 41 | "test": "rc-test" 42 | }, 43 | "dependencies": { 44 | "@babel/runtime": "^7.10.1", 45 | "classnames": "^2.2.5", 46 | "rc-util": "^5.36.0" 47 | }, 48 | "devDependencies": { 49 | "@rc-component/father-plugin": "^1.0.2", 50 | "@testing-library/jest-dom": "^6.1.5", 51 | "@testing-library/react": "^12.1.3", 52 | "@types/classnames": "^2.2.9", 53 | "@types/jest": "^29.5.1", 54 | "@types/node": "^22.5.0", 55 | "@types/react": "^18.2.42", 56 | "@types/react-dom": "^18.0.11", 57 | "@umijs/fabric": "^4.0.1", 58 | "cross-env": "^7.0.0", 59 | "dumi": "^2.2.10", 60 | "eslint": "^8.54.0", 61 | "eslint-plugin-jest": "^28.2.0", 62 | "eslint-plugin-unicorn": "^54.0.0", 63 | "father": "^4.3.5", 64 | "father-build": "^1.18.6", 65 | "gh-pages": "^6.1.0", 66 | "glob": "^7.1.6", 67 | "less": "^4.1.3", 68 | "np": "^10.0.4", 69 | "rc-test": "^7.0.15", 70 | "rc-tooltip": "^6.1.2", 71 | "rc-trigger": "^5.3.4", 72 | "react": "^16.0.0", 73 | "react-dom": "^16.0.0", 74 | "regenerator-runtime": "^0.14.0", 75 | "typescript": "^5.1.6" 76 | }, 77 | "peerDependencies": { 78 | "react": ">=16.9.0", 79 | "react-dom": ">=16.9.0" 80 | }, 81 | "engines": { 82 | "node": ">=8.x" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /script/update-content.js: -------------------------------------------------------------------------------- 1 | /* 2 | 用于 dumi 改造使用, 3 | 可用于将 examples 的文件批量修改为 demo 引入形式, 4 | 其他项目根据具体情况使用。 5 | */ 6 | 7 | const fs = require('fs'); 8 | const glob = require('glob'); 9 | 10 | const paths = glob.sync('./docs/examples/*.jsx'); 11 | 12 | paths.forEach(path => { 13 | const name = path.split('/').pop().split('.')[0]; 14 | fs.writeFile( 15 | `./docs/demo/${name}.md`, 16 | `## ${name} 17 | 18 | 19 | `, 20 | 'utf8', 21 | function(error) { 22 | if(error){ 23 | console.log(error); 24 | return false; 25 | } 26 | console.log(`${name} 更新成功~`); 27 | } 28 | ) 29 | }); 30 | -------------------------------------------------------------------------------- /src/Handles/Handle.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import KeyCode from 'rc-util/lib/KeyCode'; 3 | import * as React from 'react'; 4 | import SliderContext from '../context'; 5 | import type { OnStartMove } from '../interface'; 6 | import { getDirectionStyle, getIndex } from '../util'; 7 | 8 | interface RenderProps { 9 | index: number; 10 | prefixCls: string; 11 | value: number; 12 | dragging: boolean; 13 | draggingDelete: boolean; 14 | } 15 | 16 | export interface HandleProps 17 | extends Omit, 'onFocus' | 'onMouseEnter'> { 18 | prefixCls: string; 19 | style?: React.CSSProperties; 20 | value: number; 21 | valueIndex: number; 22 | dragging: boolean; 23 | draggingDelete: boolean; 24 | onStartMove: OnStartMove; 25 | onDelete?: (index: number) => void; 26 | onOffsetChange: (value: number | 'min' | 'max', valueIndex: number) => void; 27 | onFocus: (e: React.FocusEvent, index: number) => void; 28 | onMouseEnter: (e: React.MouseEvent, index: number) => void; 29 | render?: ( 30 | origin: React.ReactElement>, 31 | props: RenderProps, 32 | ) => React.ReactElement; 33 | onChangeComplete?: () => void; 34 | mock?: boolean; 35 | } 36 | 37 | const Handle = React.forwardRef((props, ref) => { 38 | const { 39 | prefixCls, 40 | value, 41 | valueIndex, 42 | onStartMove, 43 | onDelete, 44 | style, 45 | render, 46 | dragging, 47 | draggingDelete, 48 | onOffsetChange, 49 | onChangeComplete, 50 | onFocus, 51 | onMouseEnter, 52 | ...restProps 53 | } = props; 54 | const { 55 | min, 56 | max, 57 | direction, 58 | disabled, 59 | keyboard, 60 | range, 61 | tabIndex, 62 | ariaLabelForHandle, 63 | ariaLabelledByForHandle, 64 | ariaRequired, 65 | ariaValueTextFormatterForHandle, 66 | styles, 67 | classNames, 68 | } = React.useContext(SliderContext); 69 | 70 | const handlePrefixCls = `${prefixCls}-handle`; 71 | 72 | // ============================ Events ============================ 73 | const onInternalStartMove = (e: React.MouseEvent | React.TouchEvent) => { 74 | if (!disabled) { 75 | onStartMove(e, valueIndex); 76 | } 77 | }; 78 | 79 | const onInternalFocus = (e: React.FocusEvent) => { 80 | onFocus?.(e, valueIndex); 81 | }; 82 | 83 | const onInternalMouseEnter = (e: React.MouseEvent) => { 84 | onMouseEnter(e, valueIndex); 85 | }; 86 | 87 | // =========================== Keyboard =========================== 88 | const onKeyDown: React.KeyboardEventHandler = (e) => { 89 | if (!disabled && keyboard) { 90 | let offset: number | 'min' | 'max' = null; 91 | 92 | // Change the value 93 | switch (e.which || e.keyCode) { 94 | case KeyCode.LEFT: 95 | offset = direction === 'ltr' || direction === 'btt' ? -1 : 1; 96 | break; 97 | 98 | case KeyCode.RIGHT: 99 | offset = direction === 'ltr' || direction === 'btt' ? 1 : -1; 100 | break; 101 | 102 | // Up is plus 103 | case KeyCode.UP: 104 | offset = direction !== 'ttb' ? 1 : -1; 105 | break; 106 | 107 | // Down is minus 108 | case KeyCode.DOWN: 109 | offset = direction !== 'ttb' ? -1 : 1; 110 | break; 111 | 112 | case KeyCode.HOME: 113 | offset = 'min'; 114 | break; 115 | 116 | case KeyCode.END: 117 | offset = 'max'; 118 | break; 119 | 120 | case KeyCode.PAGE_UP: 121 | offset = 2; 122 | break; 123 | 124 | case KeyCode.PAGE_DOWN: 125 | offset = -2; 126 | break; 127 | 128 | case KeyCode.BACKSPACE: 129 | case KeyCode.DELETE: 130 | onDelete?.(valueIndex); 131 | break; 132 | } 133 | 134 | if (offset !== null) { 135 | e.preventDefault(); 136 | onOffsetChange(offset, valueIndex); 137 | } 138 | } 139 | }; 140 | 141 | const handleKeyUp = (e: React.KeyboardEvent) => { 142 | switch (e.which || e.keyCode) { 143 | case KeyCode.LEFT: 144 | case KeyCode.RIGHT: 145 | case KeyCode.UP: 146 | case KeyCode.DOWN: 147 | case KeyCode.HOME: 148 | case KeyCode.END: 149 | case KeyCode.PAGE_UP: 150 | case KeyCode.PAGE_DOWN: 151 | onChangeComplete?.(); 152 | break; 153 | } 154 | }; 155 | 156 | // ============================ Offset ============================ 157 | const positionStyle = getDirectionStyle(direction, value, min, max); 158 | 159 | // ============================ Render ============================ 160 | let divProps: React.HtmlHTMLAttributes = {}; 161 | 162 | if (valueIndex !== null) { 163 | divProps = { 164 | tabIndex: disabled ? null : getIndex(tabIndex, valueIndex), 165 | role: 'slider', 166 | 'aria-valuemin': min, 167 | 'aria-valuemax': max, 168 | 'aria-valuenow': value, 169 | 'aria-disabled': disabled, 170 | 'aria-label': getIndex(ariaLabelForHandle, valueIndex), 171 | 'aria-labelledby': getIndex(ariaLabelledByForHandle, valueIndex), 172 | 'aria-required': getIndex(ariaRequired, valueIndex), 173 | 'aria-valuetext': getIndex(ariaValueTextFormatterForHandle, valueIndex)?.(value), 174 | 'aria-orientation': direction === 'ltr' || direction === 'rtl' ? 'horizontal' : 'vertical', 175 | onMouseDown: onInternalStartMove, 176 | onTouchStart: onInternalStartMove, 177 | onFocus: onInternalFocus, 178 | onMouseEnter: onInternalMouseEnter, 179 | onKeyDown, 180 | onKeyUp: handleKeyUp, 181 | }; 182 | } 183 | 184 | let handleNode = ( 185 |
204 | ); 205 | 206 | // Customize 207 | if (render) { 208 | handleNode = render(handleNode, { 209 | index: valueIndex, 210 | prefixCls, 211 | value, 212 | dragging, 213 | draggingDelete, 214 | }); 215 | } 216 | 217 | return handleNode; 218 | }); 219 | 220 | if (process.env.NODE_ENV !== 'production') { 221 | Handle.displayName = 'Handle'; 222 | } 223 | 224 | export default Handle; 225 | -------------------------------------------------------------------------------- /src/Handles/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { flushSync } from 'react-dom'; 3 | import type { OnStartMove } from '../interface'; 4 | import { getIndex } from '../util'; 5 | import type { HandleProps } from './Handle'; 6 | import Handle from './Handle'; 7 | 8 | export interface HandlesProps { 9 | prefixCls: string; 10 | style?: React.CSSProperties | React.CSSProperties[]; 11 | values: number[]; 12 | onStartMove: OnStartMove; 13 | onOffsetChange: (value: number | 'min' | 'max', valueIndex: number) => void; 14 | onFocus?: (e: React.FocusEvent) => void; 15 | onBlur?: (e: React.FocusEvent) => void; 16 | onDelete?: (index: number) => void; 17 | handleRender?: HandleProps['render']; 18 | /** 19 | * When config `activeHandleRender`, 20 | * it will render another hidden handle for active usage. 21 | * This is useful for accessibility or tooltip usage. 22 | */ 23 | activeHandleRender?: HandleProps['render']; 24 | draggingIndex: number; 25 | draggingDelete: boolean; 26 | onChangeComplete?: () => void; 27 | } 28 | 29 | export interface HandlesRef { 30 | focus: (index: number) => void; 31 | hideHelp: VoidFunction; 32 | } 33 | 34 | const Handles = React.forwardRef((props, ref) => { 35 | const { 36 | prefixCls, 37 | style, 38 | onStartMove, 39 | onOffsetChange, 40 | values, 41 | handleRender, 42 | activeHandleRender, 43 | draggingIndex, 44 | draggingDelete, 45 | onFocus, 46 | ...restProps 47 | } = props; 48 | const handlesRef = React.useRef>({}); 49 | 50 | // =========================== Active =========================== 51 | const [activeVisible, setActiveVisible] = React.useState(false); 52 | const [activeIndex, setActiveIndex] = React.useState(-1); 53 | 54 | const onActive = (index: number) => { 55 | setActiveIndex(index); 56 | setActiveVisible(true); 57 | }; 58 | 59 | const onHandleFocus = (e: React.FocusEvent, index: number) => { 60 | onActive(index); 61 | onFocus?.(e); 62 | }; 63 | 64 | const onHandleMouseEnter = (e: React.MouseEvent, index: number) => { 65 | onActive(index); 66 | }; 67 | 68 | // =========================== Render =========================== 69 | React.useImperativeHandle(ref, () => ({ 70 | focus: (index: number) => { 71 | handlesRef.current[index]?.focus(); 72 | }, 73 | hideHelp: () => { 74 | flushSync(() => { 75 | setActiveVisible(false); 76 | }); 77 | }, 78 | })); 79 | 80 | // =========================== Render =========================== 81 | // Handle Props 82 | const handleProps = { 83 | prefixCls, 84 | onStartMove, 85 | onOffsetChange, 86 | render: handleRender, 87 | onFocus: onHandleFocus, 88 | onMouseEnter: onHandleMouseEnter, 89 | ...restProps, 90 | }; 91 | 92 | return ( 93 | <> 94 | {values.map((value, index) => { 95 | const dragging = draggingIndex === index; 96 | 97 | return ( 98 | { 100 | if (!node) { 101 | delete handlesRef.current[index]; 102 | } else { 103 | handlesRef.current[index] = node; 104 | } 105 | }} 106 | dragging={dragging} 107 | draggingDelete={dragging && draggingDelete} 108 | style={getIndex(style, index)} 109 | key={index} 110 | value={value} 111 | valueIndex={index} 112 | {...handleProps} 113 | /> 114 | ); 115 | })} 116 | 117 | {/* Used for render tooltip, this is not a real handle */} 118 | {activeHandleRender && activeVisible && ( 119 | 131 | )} 132 | 133 | ); 134 | }); 135 | 136 | if (process.env.NODE_ENV !== 'production') { 137 | Handles.displayName = 'Handles'; 138 | } 139 | 140 | export default Handles; 141 | -------------------------------------------------------------------------------- /src/Marks/Mark.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import * as React from 'react'; 3 | import SliderContext from '../context'; 4 | import { getDirectionStyle } from '../util'; 5 | 6 | export interface MarkProps { 7 | prefixCls: string; 8 | children?: React.ReactNode; 9 | style?: React.CSSProperties; 10 | value: number; 11 | onClick: (value: number) => void; 12 | } 13 | 14 | const Mark: React.FC = (props) => { 15 | const { prefixCls, style, children, value, onClick } = props; 16 | const { min, max, direction, includedStart, includedEnd, included } = 17 | React.useContext(SliderContext); 18 | 19 | const textCls = `${prefixCls}-text`; 20 | 21 | // ============================ Offset ============================ 22 | const positionStyle = getDirectionStyle(direction, value, min, max); 23 | 24 | return ( 25 | { 31 | e.stopPropagation(); 32 | }} 33 | onClick={() => { 34 | onClick(value); 35 | }} 36 | > 37 | {children} 38 | 39 | ); 40 | }; 41 | 42 | export default Mark; 43 | -------------------------------------------------------------------------------- /src/Marks/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Mark from './Mark'; 3 | 4 | export interface MarkObj { 5 | style?: React.CSSProperties; 6 | label?: React.ReactNode; 7 | } 8 | 9 | export interface InternalMarkObj extends MarkObj { 10 | value: number; 11 | } 12 | 13 | export interface MarksProps { 14 | prefixCls: string; 15 | marks?: InternalMarkObj[]; 16 | onClick: (value: number) => void; 17 | } 18 | 19 | const Marks: React.FC = (props) => { 20 | const { prefixCls, marks, onClick } = props; 21 | 22 | const markPrefixCls = `${prefixCls}-mark`; 23 | 24 | // Not render mark if empty 25 | if (!marks.length) { 26 | return null; 27 | } 28 | 29 | return ( 30 |
31 | {marks.map(({ value, style, label }) => ( 32 | 33 | {label} 34 | 35 | ))} 36 |
37 | ); 38 | }; 39 | 40 | export default Marks; 41 | -------------------------------------------------------------------------------- /src/Slider.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import useEvent from 'rc-util/lib/hooks/useEvent'; 3 | import useMergedState from 'rc-util/lib/hooks/useMergedState'; 4 | import isEqual from 'rc-util/lib/isEqual'; 5 | import warning from 'rc-util/lib/warning'; 6 | import * as React from 'react'; 7 | import type { HandlesProps, HandlesRef } from './Handles'; 8 | import Handles from './Handles'; 9 | import type { InternalMarkObj, MarkObj } from './Marks'; 10 | import Marks from './Marks'; 11 | import Steps from './Steps'; 12 | import Tracks from './Tracks'; 13 | import type { SliderContextProps } from './context'; 14 | import SliderContext from './context'; 15 | import useDrag from './hooks/useDrag'; 16 | import useOffset from './hooks/useOffset'; 17 | import useRange from './hooks/useRange'; 18 | import type { 19 | AriaValueFormat, 20 | Direction, 21 | OnStartMove, 22 | SliderClassNames, 23 | SliderStyles, 24 | } from './interface'; 25 | 26 | /** 27 | * New: 28 | * - click mark to update range value 29 | * - handleRender 30 | * - Fix handle with count not correct 31 | * - Fix pushable not work in some case 32 | * - No more FindDOMNode 33 | * - Move all position related style into inline style 34 | * - Key: up is plus, down is minus 35 | * - fix Key with step = null not align with marks 36 | * - Change range should not trigger onChange 37 | * - keyboard support pushable 38 | */ 39 | 40 | export type RangeConfig = { 41 | editable?: boolean; 42 | draggableTrack?: boolean; 43 | /** Set min count when `editable` */ 44 | minCount?: number; 45 | /** Set max count when `editable` */ 46 | maxCount?: number; 47 | }; 48 | 49 | export interface SliderProps { 50 | prefixCls?: string; 51 | className?: string; 52 | style?: React.CSSProperties; 53 | 54 | classNames?: SliderClassNames; 55 | styles?: SliderStyles; 56 | 57 | id?: string; 58 | 59 | // Status 60 | disabled?: boolean; 61 | keyboard?: boolean; 62 | autoFocus?: boolean; 63 | onFocus?: (e: React.FocusEvent) => void; 64 | onBlur?: (e: React.FocusEvent) => void; 65 | 66 | // Value 67 | range?: boolean | RangeConfig; 68 | /** @deprecated Use `range.minCount` or `range.maxCount` to handle this */ 69 | count?: number; 70 | min?: number; 71 | max?: number; 72 | step?: number | null; 73 | value?: ValueType; 74 | defaultValue?: ValueType; 75 | onChange?: (value: ValueType) => void; 76 | /** @deprecated It's always better to use `onChange` instead */ 77 | onBeforeChange?: (value: ValueType) => void; 78 | /** @deprecated Use `onChangeComplete` instead */ 79 | onAfterChange?: (value: ValueType) => void; 80 | onChangeComplete?: (value: ValueType) => void; 81 | 82 | // Cross 83 | allowCross?: boolean; 84 | pushable?: boolean | number; 85 | 86 | // Direction 87 | reverse?: boolean; 88 | vertical?: boolean; 89 | 90 | // Style 91 | included?: boolean; 92 | startPoint?: number; 93 | /** @deprecated Please use `styles.track` instead */ 94 | trackStyle?: React.CSSProperties | React.CSSProperties[]; 95 | /** @deprecated Please use `styles.handle` instead */ 96 | handleStyle?: React.CSSProperties | React.CSSProperties[]; 97 | /** @deprecated Please use `styles.rail` instead */ 98 | railStyle?: React.CSSProperties; 99 | dotStyle?: React.CSSProperties | ((dotValue: number) => React.CSSProperties); 100 | activeDotStyle?: React.CSSProperties | ((dotValue: number) => React.CSSProperties); 101 | 102 | // Decorations 103 | marks?: Record; 104 | dots?: boolean; 105 | 106 | // Components 107 | handleRender?: HandlesProps['handleRender']; 108 | activeHandleRender?: HandlesProps['handleRender']; 109 | track?: boolean; 110 | 111 | // Accessibility 112 | tabIndex?: number | number[]; 113 | ariaLabelForHandle?: string | string[]; 114 | ariaLabelledByForHandle?: string | string[]; 115 | ariaRequired?: boolean; 116 | ariaValueTextFormatterForHandle?: AriaValueFormat | AriaValueFormat[]; 117 | } 118 | 119 | export interface SliderRef { 120 | focus: () => void; 121 | blur: () => void; 122 | } 123 | 124 | const Slider = React.forwardRef>((props, ref) => { 125 | const { 126 | prefixCls = 'rc-slider', 127 | className, 128 | style, 129 | classNames, 130 | styles, 131 | 132 | id, 133 | 134 | // Status 135 | disabled = false, 136 | keyboard = true, 137 | autoFocus, 138 | onFocus, 139 | onBlur, 140 | 141 | // Value 142 | min = 0, 143 | max = 100, 144 | step = 1, 145 | value, 146 | defaultValue, 147 | range, 148 | count, 149 | onChange, 150 | onBeforeChange, 151 | onAfterChange, 152 | onChangeComplete, 153 | 154 | // Cross 155 | allowCross = true, 156 | pushable = false, 157 | 158 | // Direction 159 | reverse, 160 | vertical, 161 | 162 | // Style 163 | included = true, 164 | startPoint, 165 | trackStyle, 166 | handleStyle, 167 | railStyle, 168 | dotStyle, 169 | activeDotStyle, 170 | 171 | // Decorations 172 | marks, 173 | dots, 174 | 175 | // Components 176 | handleRender, 177 | activeHandleRender, 178 | track, 179 | 180 | // Accessibility 181 | tabIndex = 0, 182 | ariaLabelForHandle, 183 | ariaLabelledByForHandle, 184 | ariaRequired, 185 | ariaValueTextFormatterForHandle, 186 | } = props; 187 | 188 | const handlesRef = React.useRef(null); 189 | const containerRef = React.useRef(null); 190 | 191 | const direction = React.useMemo(() => { 192 | if (vertical) { 193 | return reverse ? 'ttb' : 'btt'; 194 | } 195 | return reverse ? 'rtl' : 'ltr'; 196 | }, [reverse, vertical]); 197 | 198 | // ============================ Range ============================= 199 | const [rangeEnabled, rangeEditable, rangeDraggableTrack, minCount, maxCount] = useRange(range); 200 | 201 | const mergedMin = React.useMemo(() => (isFinite(min) ? min : 0), [min]); 202 | const mergedMax = React.useMemo(() => (isFinite(max) ? max : 100), [max]); 203 | 204 | // ============================= Step ============================= 205 | const mergedStep = React.useMemo(() => (step !== null && step <= 0 ? 1 : step), [step]); 206 | 207 | // ============================= Push ============================= 208 | const mergedPush = React.useMemo(() => { 209 | if (typeof pushable === 'boolean') { 210 | return pushable ? mergedStep : false; 211 | } 212 | return pushable >= 0 ? pushable : false; 213 | }, [pushable, mergedStep]); 214 | 215 | // ============================ Marks ============================= 216 | const markList = React.useMemo(() => { 217 | return Object.keys(marks || {}) 218 | .map((key) => { 219 | const mark = marks[key]; 220 | const markObj: InternalMarkObj = { 221 | value: Number(key), 222 | }; 223 | 224 | if ( 225 | mark && 226 | typeof mark === 'object' && 227 | !React.isValidElement(mark) && 228 | ('label' in mark || 'style' in mark) 229 | ) { 230 | markObj.style = mark.style; 231 | markObj.label = mark.label; 232 | } else { 233 | markObj.label = mark as React.ReactNode; 234 | } 235 | 236 | return markObj; 237 | }) 238 | .filter(({ label }) => label || typeof label === 'number') 239 | .sort((a, b) => a.value - b.value); 240 | }, [marks]); 241 | 242 | // ============================ Format ============================ 243 | const [formatValue, offsetValues] = useOffset( 244 | mergedMin, 245 | mergedMax, 246 | mergedStep, 247 | markList, 248 | allowCross, 249 | mergedPush, 250 | ); 251 | 252 | // ============================ Values ============================ 253 | const [mergedValue, setValue] = useMergedState(defaultValue, { 254 | value, 255 | }); 256 | 257 | const rawValues = React.useMemo(() => { 258 | const valueList = 259 | mergedValue === null || mergedValue === undefined 260 | ? [] 261 | : Array.isArray(mergedValue) 262 | ? mergedValue 263 | : [mergedValue]; 264 | 265 | const [val0 = mergedMin] = valueList; 266 | let returnValues = mergedValue === null ? [] : [val0]; 267 | 268 | // Format as range 269 | if (rangeEnabled) { 270 | returnValues = [...valueList]; 271 | 272 | // When count provided or value is `undefined`, we fill values 273 | if (count || mergedValue === undefined) { 274 | const pointCount = count >= 0 ? count + 1 : 2; 275 | returnValues = returnValues.slice(0, pointCount); 276 | 277 | // Fill with count 278 | while (returnValues.length < pointCount) { 279 | returnValues.push(returnValues[returnValues.length - 1] ?? mergedMin); 280 | } 281 | } 282 | returnValues.sort((a, b) => a - b); 283 | } 284 | 285 | // Align in range 286 | returnValues.forEach((val, index) => { 287 | returnValues[index] = formatValue(val); 288 | }); 289 | 290 | return returnValues; 291 | }, [mergedValue, rangeEnabled, mergedMin, count, formatValue]); 292 | 293 | // =========================== onChange =========================== 294 | const getTriggerValue = (triggerValues: number[]) => 295 | rangeEnabled ? triggerValues : triggerValues[0]; 296 | 297 | const triggerChange = useEvent((nextValues: number[]) => { 298 | // Order first 299 | const cloneNextValues = [...nextValues].sort((a, b) => a - b); 300 | 301 | // Trigger event if needed 302 | if (onChange && !isEqual(cloneNextValues, rawValues, true)) { 303 | onChange(getTriggerValue(cloneNextValues)); 304 | } 305 | 306 | // We set this later since it will re-render component immediately 307 | setValue(cloneNextValues); 308 | }); 309 | 310 | const finishChange = useEvent((draggingDelete?: boolean) => { 311 | // Trigger from `useDrag` will tell if it's a delete action 312 | if (draggingDelete) { 313 | handlesRef.current.hideHelp(); 314 | } 315 | 316 | const finishValue = getTriggerValue(rawValues); 317 | onAfterChange?.(finishValue); 318 | warning( 319 | !onAfterChange, 320 | '[rc-slider] `onAfterChange` is deprecated. Please use `onChangeComplete` instead.', 321 | ); 322 | onChangeComplete?.(finishValue); 323 | }); 324 | 325 | const onDelete = (index: number) => { 326 | if (disabled || !rangeEditable || rawValues.length <= minCount) { 327 | return; 328 | } 329 | 330 | const cloneNextValues = [...rawValues]; 331 | cloneNextValues.splice(index, 1); 332 | 333 | onBeforeChange?.(getTriggerValue(cloneNextValues)); 334 | triggerChange(cloneNextValues); 335 | 336 | const nextFocusIndex = Math.max(0, index - 1); 337 | handlesRef.current.hideHelp(); 338 | handlesRef.current.focus(nextFocusIndex); 339 | }; 340 | 341 | const [draggingIndex, draggingValue, draggingDelete, cacheValues, onStartDrag] = useDrag( 342 | containerRef, 343 | direction, 344 | rawValues, 345 | mergedMin, 346 | mergedMax, 347 | formatValue, 348 | triggerChange, 349 | finishChange, 350 | offsetValues, 351 | rangeEditable, 352 | minCount, 353 | ); 354 | 355 | /** 356 | * When `rangeEditable` will insert a new value in the values array. 357 | * Else it will replace the value in the values array. 358 | */ 359 | const changeToCloseValue = (newValue: number, e?: React.MouseEvent) => { 360 | if (!disabled) { 361 | // Create new values 362 | const cloneNextValues = [...rawValues]; 363 | 364 | let valueIndex = 0; 365 | let valueBeforeIndex = 0; // Record the index which value < newValue 366 | let valueDist = mergedMax - mergedMin; 367 | 368 | rawValues.forEach((val, index) => { 369 | const dist = Math.abs(newValue - val); 370 | if (dist <= valueDist) { 371 | valueDist = dist; 372 | valueIndex = index; 373 | } 374 | 375 | if (val < newValue) { 376 | valueBeforeIndex = index; 377 | } 378 | }); 379 | 380 | let focusIndex = valueIndex; 381 | 382 | if (rangeEditable && valueDist !== 0 && (!maxCount || rawValues.length < maxCount)) { 383 | cloneNextValues.splice(valueBeforeIndex + 1, 0, newValue); 384 | focusIndex = valueBeforeIndex + 1; 385 | } else { 386 | cloneNextValues[valueIndex] = newValue; 387 | } 388 | 389 | // Fill value to match default 2 (only when `rawValues` is empty) 390 | if (rangeEnabled && !rawValues.length && count === undefined) { 391 | cloneNextValues.push(newValue); 392 | } 393 | 394 | const nextValue = getTriggerValue(cloneNextValues); 395 | onBeforeChange?.(nextValue); 396 | triggerChange(cloneNextValues); 397 | 398 | if (e) { 399 | (document.activeElement as HTMLElement)?.blur?.(); 400 | handlesRef.current.focus(focusIndex); 401 | onStartDrag(e, focusIndex, cloneNextValues); 402 | } else { 403 | // https://github.com/ant-design/ant-design/issues/49997 404 | onAfterChange?.(nextValue); 405 | warning( 406 | !onAfterChange, 407 | '[rc-slider] `onAfterChange` is deprecated. Please use `onChangeComplete` instead.', 408 | ); 409 | onChangeComplete?.(nextValue); 410 | } 411 | } 412 | }; 413 | 414 | // ============================ Click ============================= 415 | const onSliderMouseDown: React.MouseEventHandler = (e) => { 416 | e.preventDefault(); 417 | 418 | const { width, height, left, top, bottom, right } = 419 | containerRef.current.getBoundingClientRect(); 420 | const { clientX, clientY } = e; 421 | 422 | let percent: number; 423 | switch (direction) { 424 | case 'btt': 425 | percent = (bottom - clientY) / height; 426 | break; 427 | 428 | case 'ttb': 429 | percent = (clientY - top) / height; 430 | break; 431 | 432 | case 'rtl': 433 | percent = (right - clientX) / width; 434 | break; 435 | 436 | default: 437 | percent = (clientX - left) / width; 438 | } 439 | 440 | const nextValue = mergedMin + percent * (mergedMax - mergedMin); 441 | changeToCloseValue(formatValue(nextValue), e); 442 | }; 443 | 444 | // =========================== Keyboard =========================== 445 | const [keyboardValue, setKeyboardValue] = React.useState(null); 446 | 447 | const onHandleOffsetChange = (offset: number | 'min' | 'max', valueIndex: number) => { 448 | if (!disabled) { 449 | const next = offsetValues(rawValues, offset, valueIndex); 450 | 451 | onBeforeChange?.(getTriggerValue(rawValues)); 452 | triggerChange(next.values); 453 | 454 | setKeyboardValue(next.value); 455 | } 456 | }; 457 | 458 | React.useEffect(() => { 459 | if (keyboardValue !== null) { 460 | const valueIndex = rawValues.indexOf(keyboardValue); 461 | if (valueIndex >= 0) { 462 | handlesRef.current.focus(valueIndex); 463 | } 464 | } 465 | 466 | setKeyboardValue(null); 467 | }, [keyboardValue]); 468 | 469 | // ============================= Drag ============================= 470 | const mergedDraggableTrack = React.useMemo(() => { 471 | if (rangeDraggableTrack && mergedStep === null) { 472 | if (process.env.NODE_ENV !== 'production') { 473 | warning(false, '`draggableTrack` is not supported when `step` is `null`.'); 474 | } 475 | return false; 476 | } 477 | return rangeDraggableTrack; 478 | }, [rangeDraggableTrack, mergedStep]); 479 | 480 | const onStartMove: OnStartMove = useEvent((e, valueIndex) => { 481 | onStartDrag(e, valueIndex); 482 | 483 | onBeforeChange?.(getTriggerValue(rawValues)); 484 | }); 485 | 486 | // Auto focus for updated handle 487 | const dragging = draggingIndex !== -1; 488 | React.useEffect(() => { 489 | if (!dragging) { 490 | const valueIndex = rawValues.lastIndexOf(draggingValue); 491 | handlesRef.current.focus(valueIndex); 492 | } 493 | }, [dragging]); 494 | 495 | // =========================== Included =========================== 496 | const sortedCacheValues = React.useMemo( 497 | () => [...cacheValues].sort((a, b) => a - b), 498 | [cacheValues], 499 | ); 500 | 501 | // Provide a range values with included [min, max] 502 | // Used for Track, Mark & Dot 503 | const [includedStart, includedEnd] = React.useMemo(() => { 504 | if (!rangeEnabled) { 505 | return [mergedMin, sortedCacheValues[0]]; 506 | } 507 | 508 | return [sortedCacheValues[0], sortedCacheValues[sortedCacheValues.length - 1]]; 509 | }, [sortedCacheValues, rangeEnabled, mergedMin]); 510 | 511 | // ============================= Refs ============================= 512 | React.useImperativeHandle(ref, () => ({ 513 | focus: () => { 514 | handlesRef.current.focus(0); 515 | }, 516 | blur: () => { 517 | const { activeElement } = document; 518 | if (containerRef.current?.contains(activeElement)) { 519 | (activeElement as HTMLElement)?.blur(); 520 | } 521 | }, 522 | })); 523 | 524 | // ========================== Auto Focus ========================== 525 | React.useEffect(() => { 526 | if (autoFocus) { 527 | handlesRef.current.focus(0); 528 | } 529 | }, []); 530 | 531 | // =========================== Context ============================ 532 | const context = React.useMemo( 533 | () => ({ 534 | min: mergedMin, 535 | max: mergedMax, 536 | direction, 537 | disabled, 538 | keyboard, 539 | step: mergedStep, 540 | included, 541 | includedStart, 542 | includedEnd, 543 | range: rangeEnabled, 544 | tabIndex, 545 | ariaLabelForHandle, 546 | ariaLabelledByForHandle, 547 | ariaRequired, 548 | ariaValueTextFormatterForHandle, 549 | styles: styles || {}, 550 | classNames: classNames || {}, 551 | }), 552 | [ 553 | mergedMin, 554 | mergedMax, 555 | direction, 556 | disabled, 557 | keyboard, 558 | mergedStep, 559 | included, 560 | includedStart, 561 | includedEnd, 562 | rangeEnabled, 563 | tabIndex, 564 | ariaLabelForHandle, 565 | ariaLabelledByForHandle, 566 | ariaRequired, 567 | ariaValueTextFormatterForHandle, 568 | styles, 569 | classNames, 570 | ], 571 | ); 572 | 573 | // ============================ Render ============================ 574 | return ( 575 | 576 |
588 |
592 | 593 | {track !== false && ( 594 | 601 | )} 602 | 603 | 610 | 611 | 627 | 628 | 629 |
630 | 631 | ); 632 | }); 633 | 634 | if (process.env.NODE_ENV !== 'production') { 635 | Slider.displayName = 'Slider'; 636 | } 637 | 638 | export default Slider; 639 | -------------------------------------------------------------------------------- /src/Steps/Dot.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import * as React from 'react'; 3 | import SliderContext from '../context'; 4 | import { getDirectionStyle } from '../util'; 5 | 6 | export interface DotProps { 7 | prefixCls: string; 8 | value: number; 9 | style?: React.CSSProperties | ((dotValue: number) => React.CSSProperties); 10 | activeStyle?: React.CSSProperties | ((dotValue: number) => React.CSSProperties); 11 | } 12 | 13 | const Dot: React.FC = (props) => { 14 | const { prefixCls, value, style, activeStyle } = props; 15 | const { min, max, direction, included, includedStart, includedEnd } = 16 | React.useContext(SliderContext); 17 | 18 | const dotClassName = `${prefixCls}-dot`; 19 | const active = included && includedStart <= value && value <= includedEnd; 20 | 21 | // ============================ Offset ============================ 22 | let mergedStyle: React.CSSProperties = { 23 | ...getDirectionStyle(direction, value, min, max), 24 | ...(typeof style === 'function' ? style(value) : style), 25 | }; 26 | 27 | if (active) { 28 | mergedStyle = { 29 | ...mergedStyle, 30 | ...(typeof activeStyle === 'function' ? activeStyle(value) : activeStyle), 31 | }; 32 | } 33 | 34 | return ( 35 | 39 | ); 40 | }; 41 | 42 | export default Dot; 43 | -------------------------------------------------------------------------------- /src/Steps/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { InternalMarkObj } from '../Marks'; 3 | import SliderContext from '../context'; 4 | import Dot from './Dot'; 5 | 6 | export interface StepsProps { 7 | prefixCls: string; 8 | marks: InternalMarkObj[]; 9 | dots?: boolean; 10 | style?: React.CSSProperties | ((dotValue: number) => React.CSSProperties); 11 | activeStyle?: React.CSSProperties | ((dotValue: number) => React.CSSProperties); 12 | } 13 | 14 | const Steps: React.FC = (props) => { 15 | const { prefixCls, marks, dots, style, activeStyle } = props; 16 | const { min, max, step } = React.useContext(SliderContext); 17 | 18 | const stepDots = React.useMemo(() => { 19 | const dotSet = new Set(); 20 | 21 | // Add marks 22 | marks.forEach((mark) => { 23 | dotSet.add(mark.value); 24 | }); 25 | 26 | // Fill dots 27 | if (dots && step !== null) { 28 | let current = min; 29 | while (current <= max) { 30 | dotSet.add(current); 31 | current += step; 32 | } 33 | } 34 | 35 | return Array.from(dotSet); 36 | }, [min, max, step, dots, marks]); 37 | 38 | return ( 39 |
40 | {stepDots.map((dotValue) => ( 41 | 48 | ))} 49 |
50 | ); 51 | }; 52 | 53 | export default Steps; 54 | -------------------------------------------------------------------------------- /src/Tracks/Track.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import * as React from 'react'; 3 | import SliderContext from '../context'; 4 | import type { OnStartMove } from '../interface'; 5 | import { getOffset } from '../util'; 6 | 7 | export interface TrackProps { 8 | prefixCls: string; 9 | style?: React.CSSProperties; 10 | /** Replace with origin prefix concat className */ 11 | replaceCls?: string; 12 | start: number; 13 | end: number; 14 | index: number; 15 | onStartMove?: OnStartMove; 16 | } 17 | 18 | const Track: React.FC = (props) => { 19 | const { prefixCls, style, start, end, index, onStartMove, replaceCls } = props; 20 | const { direction, min, max, disabled, range, classNames } = React.useContext(SliderContext); 21 | 22 | const trackPrefixCls = `${prefixCls}-track`; 23 | 24 | const offsetStart = getOffset(start, min, max); 25 | const offsetEnd = getOffset(end, min, max); 26 | 27 | // ============================ Events ============================ 28 | const onInternalStartMove = (e: React.MouseEvent | React.TouchEvent) => { 29 | if (!disabled && onStartMove) { 30 | onStartMove(e, -1); 31 | } 32 | }; 33 | 34 | // ============================ Render ============================ 35 | const positionStyle: React.CSSProperties = {}; 36 | 37 | switch (direction) { 38 | case 'rtl': 39 | positionStyle.right = `${offsetStart * 100}%`; 40 | positionStyle.width = `${offsetEnd * 100 - offsetStart * 100}%`; 41 | break; 42 | 43 | case 'btt': 44 | positionStyle.bottom = `${offsetStart * 100}%`; 45 | positionStyle.height = `${offsetEnd * 100 - offsetStart * 100}%`; 46 | break; 47 | 48 | case 'ttb': 49 | positionStyle.top = `${offsetStart * 100}%`; 50 | positionStyle.height = `${offsetEnd * 100 - offsetStart * 100}%`; 51 | break; 52 | 53 | default: 54 | positionStyle.left = `${offsetStart * 100}%`; 55 | positionStyle.width = `${offsetEnd * 100 - offsetStart * 100}%`; 56 | } 57 | 58 | const className = 59 | replaceCls || 60 | cls( 61 | trackPrefixCls, 62 | { 63 | [`${trackPrefixCls}-${index + 1}`]: index !== null && range, 64 | [`${prefixCls}-track-draggable`]: onStartMove, 65 | }, 66 | classNames.track, 67 | ); 68 | 69 | return ( 70 |
76 | ); 77 | }; 78 | 79 | export default Track; 80 | -------------------------------------------------------------------------------- /src/Tracks/index.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import * as React from 'react'; 3 | import SliderContext from '../context'; 4 | import type { OnStartMove } from '../interface'; 5 | import { getIndex } from '../util'; 6 | import Track from './Track'; 7 | 8 | export interface TrackProps { 9 | prefixCls: string; 10 | style?: React.CSSProperties | React.CSSProperties[]; 11 | values: number[]; 12 | onStartMove?: OnStartMove; 13 | startPoint?: number; 14 | } 15 | 16 | const Tracks: React.FC = (props) => { 17 | const { prefixCls, style, values, startPoint, onStartMove } = props; 18 | const { included, range, min, styles, classNames } = React.useContext(SliderContext); 19 | 20 | // =========================== List =========================== 21 | const trackList = React.useMemo(() => { 22 | if (!range) { 23 | // null value do not have track 24 | if (values.length === 0) { 25 | return []; 26 | } 27 | 28 | const startValue = startPoint ?? min; 29 | const endValue = values[0]; 30 | 31 | return [{ start: Math.min(startValue, endValue), end: Math.max(startValue, endValue) }]; 32 | } 33 | 34 | // Multiple 35 | const list: { start: number; end: number }[] = []; 36 | 37 | for (let i = 0; i < values.length - 1; i += 1) { 38 | list.push({ start: values[i], end: values[i + 1] }); 39 | } 40 | 41 | return list; 42 | }, [values, range, startPoint, min]); 43 | 44 | if (!included) { 45 | return null; 46 | } 47 | 48 | // ========================== Render ========================== 49 | const tracksNode = 50 | trackList?.length && (classNames.tracks || styles.tracks) ? ( 51 | 59 | ) : null; 60 | 61 | return ( 62 | <> 63 | {tracksNode} 64 | {trackList.map(({ start, end }, index) => ( 65 | 74 | ))} 75 | 76 | ); 77 | }; 78 | 79 | export default Tracks; 80 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { AriaValueFormat, Direction, SliderClassNames, SliderStyles } from './interface'; 3 | 4 | export interface SliderContextProps { 5 | min: number; 6 | max: number; 7 | includedStart: number; 8 | includedEnd: number; 9 | direction: Direction; 10 | disabled?: boolean; 11 | keyboard?: boolean; 12 | included?: boolean; 13 | step: number | null; 14 | range?: boolean; 15 | tabIndex: number | number[]; 16 | ariaLabelForHandle?: string | string[]; 17 | ariaLabelledByForHandle?: string | string[]; 18 | ariaRequired?: boolean; 19 | ariaValueTextFormatterForHandle?: AriaValueFormat | AriaValueFormat[]; 20 | classNames: SliderClassNames; 21 | styles: SliderStyles; 22 | } 23 | 24 | const SliderContext = React.createContext({ 25 | min: 0, 26 | max: 0, 27 | direction: 'ltr', 28 | step: 1, 29 | includedStart: 0, 30 | includedEnd: 0, 31 | tabIndex: 0, 32 | keyboard: true, 33 | styles: {}, 34 | classNames: {}, 35 | }); 36 | 37 | export default SliderContext; 38 | 39 | export interface UnstableContextProps { 40 | onDragStart?: (info: { 41 | rawValues: number[]; 42 | draggingIndex: number; 43 | draggingValue: number; 44 | }) => void; 45 | onDragChange?: (info: { 46 | rawValues: number[]; 47 | deleteIndex: number; 48 | draggingIndex: number; 49 | draggingValue: number; 50 | }) => void; 51 | } 52 | 53 | /** @private NOT PROMISE AVAILABLE. DO NOT USE IN PRODUCTION. */ 54 | export const UnstableContext = React.createContext({}); 55 | -------------------------------------------------------------------------------- /src/hooks/useDrag.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import useEvent from 'rc-util/lib/hooks/useEvent'; 3 | import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; 4 | import { UnstableContext } from '../context'; 5 | import type { Direction, OnStartMove } from '../interface'; 6 | import type { OffsetValues } from './useOffset'; 7 | 8 | /** Drag to delete offset. It's a user experience number for dragging out */ 9 | const REMOVE_DIST = 130; 10 | 11 | function getPosition(e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) { 12 | const obj = 'targetTouches' in e ? e.targetTouches[0] : e; 13 | 14 | return { pageX: obj.pageX, pageY: obj.pageY }; 15 | } 16 | 17 | function useDrag( 18 | containerRef: React.RefObject, 19 | direction: Direction, 20 | rawValues: number[], 21 | min: number, 22 | max: number, 23 | formatValue: (value: number) => number, 24 | triggerChange: (values: number[]) => void, 25 | finishChange: (draggingDelete: boolean) => void, 26 | offsetValues: OffsetValues, 27 | editable: boolean, 28 | minCount: number, 29 | ): [ 30 | draggingIndex: number, 31 | draggingValue: number, 32 | draggingDelete: boolean, 33 | returnValues: number[], 34 | onStartMove: OnStartMove, 35 | ] { 36 | const [draggingValue, setDraggingValue] = React.useState(null); 37 | const [draggingIndex, setDraggingIndex] = React.useState(-1); 38 | const [draggingDelete, setDraggingDelete] = React.useState(false); 39 | const [cacheValues, setCacheValues] = React.useState(rawValues); 40 | const [originValues, setOriginValues] = React.useState(rawValues); 41 | 42 | const mouseMoveEventRef = React.useRef<(event: MouseEvent) => void>(null); 43 | const mouseUpEventRef = React.useRef<(event: MouseEvent) => void>(null); 44 | const touchEventTargetRef = React.useRef(null); 45 | 46 | const { onDragStart, onDragChange } = React.useContext(UnstableContext); 47 | 48 | useLayoutEffect(() => { 49 | if (draggingIndex === -1) { 50 | setCacheValues(rawValues); 51 | } 52 | }, [rawValues, draggingIndex]); 53 | 54 | // Clean up event 55 | React.useEffect( 56 | () => () => { 57 | document.removeEventListener('mousemove', mouseMoveEventRef.current); 58 | document.removeEventListener('mouseup', mouseUpEventRef.current); 59 | if (touchEventTargetRef.current) { 60 | touchEventTargetRef.current.removeEventListener('touchmove', mouseMoveEventRef.current); 61 | touchEventTargetRef.current.removeEventListener('touchend', mouseUpEventRef.current); 62 | } 63 | }, 64 | [], 65 | ); 66 | 67 | const flushValues = (nextValues: number[], nextValue?: number, deleteMark?: boolean) => { 68 | // Perf: Only update state when value changed 69 | if (nextValue !== undefined) { 70 | setDraggingValue(nextValue); 71 | } 72 | setCacheValues(nextValues); 73 | 74 | let changeValues = nextValues; 75 | if (deleteMark) { 76 | changeValues = nextValues.filter((_, i) => i !== draggingIndex); 77 | } 78 | triggerChange(changeValues); 79 | 80 | if (onDragChange) { 81 | onDragChange({ 82 | rawValues: nextValues, 83 | deleteIndex: deleteMark ? draggingIndex : -1, 84 | draggingIndex, 85 | draggingValue: nextValue, 86 | }); 87 | } 88 | }; 89 | 90 | const updateCacheValue = useEvent( 91 | (valueIndex: number, offsetPercent: number, deleteMark: boolean) => { 92 | if (valueIndex === -1) { 93 | // >>>> Dragging on the track 94 | const startValue = originValues[0]; 95 | const endValue = originValues[originValues.length - 1]; 96 | const maxStartOffset = min - startValue; 97 | const maxEndOffset = max - endValue; 98 | 99 | // Get valid offset 100 | let offset = offsetPercent * (max - min); 101 | offset = Math.max(offset, maxStartOffset); 102 | offset = Math.min(offset, maxEndOffset); 103 | 104 | // Use first value to revert back of valid offset (like steps marks) 105 | const formatStartValue = formatValue(startValue + offset); 106 | offset = formatStartValue - startValue; 107 | const cloneCacheValues = originValues.map((val) => val + offset); 108 | flushValues(cloneCacheValues); 109 | } else { 110 | // >>>> Dragging on the handle 111 | const offsetDist = (max - min) * offsetPercent; 112 | 113 | // Always start with the valueIndex origin value 114 | const cloneValues = [...cacheValues]; 115 | cloneValues[valueIndex] = originValues[valueIndex]; 116 | 117 | const next = offsetValues(cloneValues, offsetDist, valueIndex, 'dist'); 118 | 119 | flushValues(next.values, next.value, deleteMark); 120 | } 121 | }, 122 | ); 123 | 124 | const onStartMove: OnStartMove = (e, valueIndex, startValues?: number[]) => { 125 | e.stopPropagation(); 126 | 127 | // 如果是点击 track 触发的,需要传入变化后的初始值,而不能直接用 rawValues 128 | const initialValues = startValues || rawValues; 129 | const originValue = initialValues[valueIndex]; 130 | 131 | setDraggingIndex(valueIndex); 132 | setDraggingValue(originValue); 133 | setOriginValues(initialValues); 134 | setCacheValues(initialValues); 135 | setDraggingDelete(false); 136 | 137 | const { pageX: startX, pageY: startY } = getPosition(e); 138 | 139 | // We declare it here since closure can't get outer latest value 140 | let deleteMark = false; 141 | 142 | // Internal trigger event 143 | if (onDragStart) { 144 | onDragStart({ 145 | rawValues: initialValues, 146 | draggingIndex: valueIndex, 147 | draggingValue: originValue, 148 | }); 149 | } 150 | 151 | // Moving 152 | const onMouseMove = (event: MouseEvent | TouchEvent) => { 153 | event.preventDefault(); 154 | 155 | const { pageX: moveX, pageY: moveY } = getPosition(event); 156 | const offsetX = moveX - startX; 157 | const offsetY = moveY - startY; 158 | 159 | const { width, height } = containerRef.current.getBoundingClientRect(); 160 | 161 | let offSetPercent: number; 162 | let removeDist: number; 163 | 164 | switch (direction) { 165 | case 'btt': 166 | offSetPercent = -offsetY / height; 167 | removeDist = offsetX; 168 | break; 169 | 170 | case 'ttb': 171 | offSetPercent = offsetY / height; 172 | removeDist = offsetX; 173 | break; 174 | 175 | case 'rtl': 176 | offSetPercent = -offsetX / width; 177 | removeDist = offsetY; 178 | break; 179 | 180 | default: 181 | offSetPercent = offsetX / width; 182 | removeDist = offsetY; 183 | } 184 | 185 | // Check if need mark remove 186 | deleteMark = editable 187 | ? Math.abs(removeDist) > REMOVE_DIST && minCount < cacheValues.length 188 | : false; 189 | setDraggingDelete(deleteMark); 190 | 191 | updateCacheValue(valueIndex, offSetPercent, deleteMark); 192 | }; 193 | 194 | // End 195 | const onMouseUp = (event: MouseEvent | TouchEvent) => { 196 | event.preventDefault(); 197 | 198 | document.removeEventListener('mouseup', onMouseUp); 199 | document.removeEventListener('mousemove', onMouseMove); 200 | if (touchEventTargetRef.current) { 201 | touchEventTargetRef.current.removeEventListener('touchmove', mouseMoveEventRef.current); 202 | touchEventTargetRef.current.removeEventListener('touchend', mouseUpEventRef.current); 203 | } 204 | mouseMoveEventRef.current = null; 205 | mouseUpEventRef.current = null; 206 | touchEventTargetRef.current = null; 207 | 208 | finishChange(deleteMark); 209 | 210 | setDraggingIndex(-1); 211 | setDraggingDelete(false); 212 | }; 213 | 214 | document.addEventListener('mouseup', onMouseUp); 215 | document.addEventListener('mousemove', onMouseMove); 216 | e.currentTarget.addEventListener('touchend', onMouseUp); 217 | e.currentTarget.addEventListener('touchmove', onMouseMove); 218 | mouseMoveEventRef.current = onMouseMove; 219 | mouseUpEventRef.current = onMouseUp; 220 | touchEventTargetRef.current = e.currentTarget; 221 | }; 222 | 223 | // Only return cache value when it mapping with rawValues 224 | const returnValues = React.useMemo(() => { 225 | const sourceValues = [...rawValues].sort((a, b) => a - b); 226 | const targetValues = [...cacheValues].sort((a, b) => a - b); 227 | 228 | const counts: Record = {}; 229 | targetValues.forEach((val) => { 230 | counts[val] = (counts[val] || 0) + 1; 231 | }); 232 | sourceValues.forEach((val) => { 233 | counts[val] = (counts[val] || 0) - 1; 234 | }); 235 | 236 | const maxDiffCount = editable ? 1 : 0; 237 | const diffCount: number = Object.values(counts).reduce( 238 | (prev, next) => prev + Math.abs(next), 239 | 0, 240 | ); 241 | 242 | return diffCount <= maxDiffCount ? cacheValues : rawValues; 243 | }, [rawValues, cacheValues, editable]); 244 | 245 | return [draggingIndex, draggingValue, draggingDelete, returnValues, onStartMove]; 246 | } 247 | 248 | export default useDrag; 249 | -------------------------------------------------------------------------------- /src/hooks/useOffset.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { InternalMarkObj } from '../Marks'; 3 | 4 | /** Format the value in the range of [min, max] */ 5 | type FormatRangeValue = (value: number) => number; 6 | 7 | /** Format value align with step */ 8 | type FormatStepValue = (value: number) => number; 9 | 10 | /** Format value align with step & marks */ 11 | type FormatValue = (value: number) => number; 12 | 13 | type OffsetMode = 'unit' | 'dist'; 14 | 15 | type OffsetValue = ( 16 | values: number[], 17 | offset: number | 'min' | 'max', 18 | valueIndex: number, 19 | mode?: OffsetMode, 20 | ) => number; 21 | 22 | export type OffsetValues = ( 23 | values: number[], 24 | offset: number | 'min' | 'max', 25 | valueIndex: number, 26 | mode?: OffsetMode, 27 | ) => { 28 | value: number; 29 | values: number[]; 30 | }; 31 | 32 | export default function useOffset( 33 | min: number, 34 | max: number, 35 | step: number, 36 | markList: InternalMarkObj[], 37 | allowCross: boolean, 38 | pushable: false | number, 39 | ): [FormatValue, OffsetValues] { 40 | const formatRangeValue: FormatRangeValue = React.useCallback( 41 | (val) => Math.max(min, Math.min(max, val)), 42 | [min, max], 43 | ); 44 | 45 | const formatStepValue: FormatStepValue = React.useCallback( 46 | (val) => { 47 | if (step !== null) { 48 | const stepValue = min + Math.round((formatRangeValue(val) - min) / step) * step; 49 | 50 | // Cut number in case to be like 0.30000000000000004 51 | const getDecimal = (num: number) => (String(num).split('.')[1] || '').length; 52 | const maxDecimal = Math.max(getDecimal(step), getDecimal(max), getDecimal(min)); 53 | const fixedValue = Number(stepValue.toFixed(maxDecimal)); 54 | 55 | return min <= fixedValue && fixedValue <= max ? fixedValue : null; 56 | } 57 | return null; 58 | }, 59 | [step, min, max, formatRangeValue], 60 | ); 61 | 62 | const formatValue = React.useCallback( 63 | (val) => { 64 | const formatNextValue = formatRangeValue(val); 65 | 66 | // List align values 67 | const alignValues = markList.map((mark) => mark.value); 68 | if (step !== null) { 69 | alignValues.push(formatStepValue(val)); 70 | } 71 | 72 | // min & max 73 | alignValues.push(min, max); 74 | 75 | // Align with marks 76 | let closeValue = alignValues[0]; 77 | let closeDist = max - min; 78 | 79 | alignValues.forEach((alignValue) => { 80 | const dist = Math.abs(formatNextValue - alignValue); 81 | if (dist <= closeDist) { 82 | closeValue = alignValue; 83 | closeDist = dist; 84 | } 85 | }); 86 | 87 | return closeValue; 88 | }, 89 | [min, max, markList, step, formatRangeValue, formatStepValue], 90 | ); 91 | 92 | // ========================== Offset ========================== 93 | // Single Value 94 | const offsetValue: OffsetValue = (values, offset, valueIndex, mode = 'unit') => { 95 | if (typeof offset === 'number') { 96 | let nextValue: number; 97 | const originValue = values[valueIndex]; 98 | 99 | // Only used for `dist` mode 100 | const targetDistValue = originValue + offset; 101 | 102 | // Compare next step value & mark value which is best match 103 | let potentialValues: number[] = []; 104 | markList.forEach((mark) => { 105 | potentialValues.push(mark.value); 106 | }); 107 | 108 | // Min & Max 109 | potentialValues.push(min, max); 110 | 111 | // In case origin value is align with mark but not with step 112 | potentialValues.push(formatStepValue(originValue)); 113 | 114 | // Put offset step value also 115 | const sign = offset > 0 ? 1 : -1; 116 | 117 | if (mode === 'unit') { 118 | potentialValues.push(formatStepValue(originValue + sign * step)); 119 | } else { 120 | potentialValues.push(formatStepValue(targetDistValue)); 121 | } 122 | 123 | // Find close one 124 | potentialValues = potentialValues 125 | .filter((val) => val !== null) 126 | // Remove reverse value 127 | .filter((val) => (offset < 0 ? val <= originValue : val >= originValue)); 128 | 129 | if (mode === 'unit') { 130 | // `unit` mode can not contain itself 131 | potentialValues = potentialValues.filter((val) => val !== originValue); 132 | } 133 | 134 | const compareValue = mode === 'unit' ? originValue : targetDistValue; 135 | 136 | nextValue = potentialValues[0]; 137 | let valueDist = Math.abs(nextValue - compareValue); 138 | 139 | potentialValues.forEach((potentialValue) => { 140 | const dist = Math.abs(potentialValue - compareValue); 141 | if (dist < valueDist) { 142 | nextValue = potentialValue; 143 | valueDist = dist; 144 | } 145 | }); 146 | 147 | // Out of range will back to range 148 | if (nextValue === undefined) { 149 | return offset < 0 ? min : max; 150 | } 151 | 152 | // `dist` mode 153 | if (mode === 'dist') { 154 | return nextValue; 155 | } 156 | 157 | // `unit` mode may need another round 158 | if (Math.abs(offset) > 1) { 159 | const cloneValues = [...values]; 160 | cloneValues[valueIndex] = nextValue; 161 | 162 | return offsetValue(cloneValues, offset - sign, valueIndex, mode); 163 | } 164 | 165 | return nextValue; 166 | } else if (offset === 'min') { 167 | return min; 168 | } else if (offset === 'max') { 169 | return max; 170 | } 171 | }; 172 | 173 | /** Same as `offsetValue` but return `changed` mark to tell value changed */ 174 | const offsetChangedValue = ( 175 | values: number[], 176 | offset: number, 177 | valueIndex: number, 178 | mode: OffsetMode = 'unit', 179 | ) => { 180 | const originValue = values[valueIndex]; 181 | const nextValue = offsetValue(values, offset, valueIndex, mode); 182 | return { 183 | value: nextValue, 184 | changed: nextValue !== originValue, 185 | }; 186 | }; 187 | 188 | const needPush = (dist: number) => { 189 | return (pushable === null && dist === 0) || (typeof pushable === 'number' && dist < pushable); 190 | }; 191 | 192 | // Values 193 | const offsetValues: OffsetValues = (values, offset, valueIndex, mode = 'unit') => { 194 | const nextValues = values.map(formatValue); 195 | const originValue = nextValues[valueIndex]; 196 | const nextValue = offsetValue(nextValues, offset, valueIndex, mode); 197 | nextValues[valueIndex] = nextValue; 198 | 199 | if (allowCross === false) { 200 | // >>>>> Allow Cross 201 | const pushNum = pushable || 0; 202 | 203 | // ============ AllowCross =============== 204 | if (valueIndex > 0 && nextValues[valueIndex - 1] !== originValue) { 205 | nextValues[valueIndex] = Math.max( 206 | nextValues[valueIndex], 207 | nextValues[valueIndex - 1] + pushNum, 208 | ); 209 | } 210 | 211 | if (valueIndex < nextValues.length - 1 && nextValues[valueIndex + 1] !== originValue) { 212 | nextValues[valueIndex] = Math.min( 213 | nextValues[valueIndex], 214 | nextValues[valueIndex + 1] - pushNum, 215 | ); 216 | } 217 | } else if (typeof pushable === 'number' || pushable === null) { 218 | // >>>>> Pushable 219 | // =============== Push ================== 220 | 221 | // >>>>>> Basic push 222 | // End values 223 | for (let i = valueIndex + 1; i < nextValues.length; i += 1) { 224 | let changed = true; 225 | while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { 226 | ({ value: nextValues[i], changed } = offsetChangedValue(nextValues, 1, i)); 227 | } 228 | } 229 | 230 | // Start values 231 | for (let i = valueIndex; i > 0; i -= 1) { 232 | let changed = true; 233 | while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { 234 | ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); 235 | } 236 | } 237 | 238 | // >>>>> Revert back to safe push range 239 | // End to Start 240 | for (let i = nextValues.length - 1; i > 0; i -= 1) { 241 | let changed = true; 242 | while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { 243 | ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); 244 | } 245 | } 246 | 247 | // Start to End 248 | for (let i = 0; i < nextValues.length - 1; i += 1) { 249 | let changed = true; 250 | while (needPush(nextValues[i + 1] - nextValues[i]) && changed) { 251 | ({ value: nextValues[i + 1], changed } = offsetChangedValue(nextValues, 1, i + 1)); 252 | } 253 | } 254 | } 255 | 256 | return { 257 | value: nextValues[valueIndex], 258 | values: nextValues, 259 | }; 260 | }; 261 | 262 | return [formatValue, offsetValues]; 263 | } 264 | -------------------------------------------------------------------------------- /src/hooks/useRange.ts: -------------------------------------------------------------------------------- 1 | import { warning } from 'rc-util/lib/warning'; 2 | import { useMemo } from 'react'; 3 | import type { SliderProps } from '../Slider'; 4 | 5 | export default function useRange( 6 | range?: SliderProps['range'], 7 | ): [ 8 | range: boolean, 9 | rangeEditable: boolean, 10 | rangeDraggableTrack: boolean, 11 | minCount: number, 12 | maxCount?: number, 13 | ] { 14 | return useMemo(() => { 15 | if (range === true || !range) { 16 | return [!!range, false, false, 0]; 17 | } 18 | 19 | const { editable, draggableTrack, minCount, maxCount } = range; 20 | 21 | if (process.env.NODE_ENV !== 'production') { 22 | warning(!editable || !draggableTrack, '`editable` can not work with `draggableTrack`.'); 23 | } 24 | 25 | return [true, editable, !editable && draggableTrack, minCount || 0, maxCount]; 26 | }, [range]); 27 | } 28 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import type { SliderProps, SliderRef } from './Slider'; 2 | import Slider from './Slider'; 3 | export { UnstableContext } from './context'; 4 | 5 | export type { SliderProps, SliderRef }; 6 | 7 | export default Slider; 8 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | 3 | export type Direction = 'rtl' | 'ltr' | 'ttb' | 'btt'; 4 | 5 | export type OnStartMove = ( 6 | e: React.MouseEvent | React.TouchEvent, 7 | valueIndex: number, 8 | startValues?: number[], 9 | ) => void; 10 | 11 | export type AriaValueFormat = (value: number) => string; 12 | 13 | export type SemanticName = 'tracks' | 'track' | 'rail' | 'handle'; 14 | 15 | export type SliderClassNames = Partial>; 16 | 17 | export type SliderStyles = Partial>; 18 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import type { Direction } from './interface'; 2 | 3 | export function getOffset(value: number, min: number, max: number) { 4 | return (value - min) / (max - min); 5 | } 6 | 7 | export function getDirectionStyle(direction: Direction, value: number, min: number, max: number) { 8 | const offset = getOffset(value, min, max); 9 | 10 | const positionStyle: React.CSSProperties = {}; 11 | 12 | switch (direction) { 13 | case 'rtl': 14 | positionStyle.right = `${offset * 100}%`; 15 | positionStyle.transform = 'translateX(50%)'; 16 | break; 17 | 18 | case 'btt': 19 | positionStyle.bottom = `${offset * 100}%`; 20 | positionStyle.transform = 'translateY(50%)'; 21 | break; 22 | 23 | case 'ttb': 24 | positionStyle.top = `${offset * 100}%`; 25 | positionStyle.transform = 'translateY(-50%)'; 26 | break; 27 | 28 | default: 29 | positionStyle.left = `${offset * 100}%`; 30 | positionStyle.transform = 'translateX(-50%)'; 31 | break; 32 | } 33 | 34 | return positionStyle; 35 | } 36 | 37 | /** Return index value if is list or return value directly */ 38 | export function getIndex(value: T | T[], index: number) { 39 | return Array.isArray(value) ? value[index] : value; 40 | } 41 | -------------------------------------------------------------------------------- /tests/Range.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len, no-undef, react/no-string-refs, no-param-reassign, max-classes-per-file */ 2 | import '@testing-library/jest-dom'; 3 | import { createEvent, fireEvent, render } from '@testing-library/react'; 4 | import keyCode from 'rc-util/lib/KeyCode'; 5 | import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; 6 | import { resetWarned } from 'rc-util/lib/warning'; 7 | import React from 'react'; 8 | import Slider from '../src'; 9 | 10 | describe('Range', () => { 11 | beforeAll(() => { 12 | spyElementPrototypes(HTMLElement, { 13 | getBoundingClientRect: () => ({ 14 | width: 100, 15 | height: 100, 16 | left: 0, 17 | top: 0, 18 | bottom: 100, 19 | right: 100, 20 | }), 21 | }); 22 | }); 23 | 24 | beforeEach(() => { 25 | resetWarned(); 26 | }); 27 | 28 | function doMouseDown( 29 | container: HTMLElement, 30 | start: number, 31 | element = 'rc-slider-handle', 32 | skipEventCheck = false, 33 | ) { 34 | const ele = container.getElementsByClassName(element)[0]; 35 | const mouseDown = createEvent.mouseDown(ele); 36 | (mouseDown as any).pageX = start; 37 | (mouseDown as any).pageY = start; 38 | 39 | const preventDefault = jest.fn(); 40 | 41 | Object.defineProperties(mouseDown, { 42 | clientX: { get: () => start }, 43 | clientY: { get: () => start }, 44 | preventDefault: { value: preventDefault }, 45 | }); 46 | 47 | fireEvent.mouseEnter(ele); 48 | fireEvent(ele, mouseDown); 49 | 50 | // Should not prevent default since focus will not change 51 | if (!skipEventCheck) { 52 | expect(preventDefault).not.toHaveBeenCalled(); 53 | } 54 | } 55 | 56 | function doMouseDrag(end: number) { 57 | const mouseMove = createEvent.mouseMove(document); 58 | (mouseMove as any).pageX = end; 59 | (mouseMove as any).pageY = end; 60 | fireEvent(document, mouseMove); 61 | } 62 | 63 | function doMouseMove( 64 | container: HTMLElement, 65 | start: number, 66 | end: number, 67 | element = 'rc-slider-handle', 68 | ) { 69 | doMouseDown(container, start, element); 70 | 71 | // Drag 72 | doMouseDrag(end); 73 | } 74 | 75 | function doTouchMove( 76 | container: HTMLElement, 77 | start: number, 78 | end: number, 79 | element = 'rc-slider-handle', 80 | ) { 81 | const touchStart = createEvent.touchStart(container.getElementsByClassName(element)[0], { 82 | touches: [{}], 83 | targetTouches: [{}], 84 | }); 85 | (touchStart as any).targetTouches[0].pageX = start; 86 | fireEvent(container.getElementsByClassName(element)[0], touchStart); 87 | 88 | // Drag 89 | const touchMove = createEvent.touchMove(container.getElementsByClassName(element)[0], { 90 | touches: [{}], 91 | targetTouches: [{}], 92 | }); 93 | (touchMove as any).targetTouches[0].pageX = end; 94 | fireEvent(container.getElementsByClassName(element)[0], touchMove); 95 | } 96 | 97 | it('should render Range with correct DOM structure', () => { 98 | const { asFragment } = render(); 99 | expect(asFragment().firstChild).toMatchSnapshot(); 100 | }); 101 | 102 | it('should render Multi-Range with correct DOM structure', () => { 103 | const { asFragment } = render(); 104 | expect(asFragment().firstChild).toMatchSnapshot(); 105 | }); 106 | 107 | it('should render Range with value correctly', async () => { 108 | const { container } = render(); 109 | 110 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle('left: 0%'); 111 | expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveStyle('left: 50%'); 112 | 113 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle( 114 | 'left: 0%; width: 50%', 115 | ); 116 | }); 117 | 118 | it('should render reverse Range with value correctly', () => { 119 | const { container } = render(); 120 | 121 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle('right: 0%'); 122 | expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveStyle('right: 50%'); 123 | 124 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle( 125 | 'right: 0%; width: 50%', 126 | ); 127 | }); 128 | 129 | it('should render Range with tabIndex correctly', () => { 130 | const { container } = render(); 131 | 132 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 133 | 'tabIndex', 134 | '1', 135 | ); 136 | expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( 137 | 'tabIndex', 138 | '2', 139 | ); 140 | }); 141 | 142 | it('should render Range without tabIndex (equal null) correctly', () => { 143 | const { container } = render(); 144 | expect(container.getElementsByClassName('rc-slider-handle')[0]).not.toHaveAttribute('tabIndex'); 145 | expect(container.getElementsByClassName('rc-slider-handle')[1]).not.toHaveAttribute('tabIndex'); 146 | }); 147 | 148 | it('it should trigger onAfterChange when key pressed', () => { 149 | const onAfterChange = jest.fn(); 150 | const { container } = render( 151 | , 152 | ); 153 | 154 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 155 | keyCode: keyCode.RIGHT, 156 | }); 157 | expect(onAfterChange).not.toHaveBeenCalled(); 158 | 159 | fireEvent.keyUp(container.getElementsByClassName('rc-slider-handle')[1], { 160 | keyCode: keyCode.RIGHT, 161 | }); 162 | 163 | expect(onAfterChange).toHaveBeenCalled(); 164 | }); 165 | 166 | it('should not change value from keyboard events when disabled', () => { 167 | const onAfterChange = jest.fn(); 168 | const { container } = render( 169 | , 170 | ); 171 | 172 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 173 | keyCode: keyCode.RIGHT, 174 | }); 175 | 176 | expect(onAfterChange).not.toBeCalled(); 177 | }); 178 | 179 | it('should render Multi-Range with value correctly', () => { 180 | const { container } = render(); 181 | 182 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle('left: 0%'); 183 | expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveStyle('left: 25%'); 184 | expect(container.getElementsByClassName('rc-slider-handle')[2]).toHaveStyle('left: 50%'); 185 | expect(container.getElementsByClassName('rc-slider-handle')[3]).toHaveStyle('left: 75%'); 186 | 187 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle( 188 | 'left: 0%; width: 25%', 189 | ); 190 | 191 | expect(container.getElementsByClassName('rc-slider-track')[1]).toHaveStyle( 192 | 'left: 25%; width: 25%', 193 | ); 194 | 195 | expect(container.getElementsByClassName('rc-slider-track')[2]).toHaveStyle( 196 | 'left: 50%; width: 25%', 197 | ); 198 | }); 199 | 200 | it('should update Range correctly in controlled model', () => { 201 | const { container, rerender } = render(); 202 | expect(container.getElementsByClassName('rc-slider-handle')).toHaveLength(3); 203 | 204 | rerender(); 205 | expect(container.getElementsByClassName('rc-slider-handle')).toHaveLength(2); 206 | }); 207 | 208 | it('not moved if controlled', () => { 209 | const onChange = jest.fn(); 210 | const { container } = render(); 211 | doMouseMove(container, 0, 9999999); 212 | 213 | expect(onChange).toHaveBeenCalled(); 214 | 215 | expect(container.querySelector('.rc-slider-handle-dragging')).toHaveStyle({ 216 | left: '2%', 217 | }); 218 | }); 219 | 220 | // Not trigger onChange anymore 221 | // it('should only update bounds that are out of range', () => { 222 | // const props = { min: 0, max: 10000, value: [0.01, 10000], onChange: jest.fn() }; 223 | // const range = mount(); 224 | // range.setProps({ min: 0, max: 500 }); 225 | 226 | // expect(props.onChange).toHaveBeenCalledWith([0.01, 500]); 227 | // }); 228 | 229 | // Not trigger onChange anymore 230 | // it('should only update bounds if they are out of range', () => { 231 | // const props = { min: 0, max: 10000, value: [0.01, 10000], onChange: jest.fn() }; 232 | // const range = mount(); 233 | // range.setProps({ min: 0, max: 500, value: [0.01, 466] }); 234 | 235 | // expect(props.onChange).toHaveBeenCalledTimes(0); 236 | // }); 237 | 238 | // https://github.com/react-component/slider/pull/256 239 | // Move to antd instead 240 | // it('should handle multi handle mouseEnter correctly', () => { 241 | // const wrapper = mount(); 242 | // wrapper.find('.rc-slider-handle').at(1).simulate('mouseEnter'); 243 | // expect(wrapper.state().visibles[0]).toBe(true); 244 | // wrapper.find('.rc-slider-handle').at(3).simulate('mouseEnter'); 245 | // expect(wrapper.state().visibles[1]).toBe(true); 246 | // wrapper.find('.rc-slider-handle').at(1).simulate('mouseLeave'); 247 | // expect(wrapper.state().visibles[0]).toBe(false); 248 | // wrapper.find('.rc-slider-handle').at(3).simulate('mouseLeave'); 249 | // expect(wrapper.state().visibles[1]).toBe(false); 250 | // }); 251 | 252 | it('should keep pushable when not allowCross', () => { 253 | const onChange = jest.fn(); 254 | const { container } = render( 255 | , 256 | ); 257 | 258 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 259 | keyCode: keyCode.UP, 260 | }); 261 | expect(onChange).toHaveBeenCalledWith([30, 40]); 262 | 263 | onChange.mockReset(); 264 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 265 | keyCode: keyCode.UP, 266 | }); 267 | expect(onChange).not.toHaveBeenCalled(); 268 | 269 | onChange.mockReset(); 270 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 271 | keyCode: keyCode.UP, 272 | }); 273 | expect(onChange).toHaveBeenCalledWith([30, 41]); 274 | 275 | // Push to the edge 276 | for (let i = 0; i < 99; i += 1) { 277 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 278 | keyCode: keyCode.DOWN, 279 | }); 280 | } 281 | expect(onChange).toHaveBeenCalledWith([30, 40]); 282 | 283 | onChange.mockReset(); 284 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 285 | keyCode: keyCode.DOWN, 286 | }); 287 | expect(onChange).not.toHaveBeenCalled(); 288 | }); 289 | 290 | it('pushable & allowCross', () => { 291 | const onChange = jest.fn(); 292 | const { container } = render( 293 | , 294 | ); 295 | 296 | // Left to Right 297 | for (let i = 0; i < 99; i += 1) { 298 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 299 | keyCode: keyCode.UP, 300 | }); 301 | } 302 | expect(onChange).toHaveBeenCalledWith([80, 90, 100]); 303 | 304 | // Center to Left 305 | for (let i = 0; i < 99; i += 1) { 306 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 307 | keyCode: keyCode.DOWN, 308 | }); 309 | } 310 | expect(onChange).toHaveBeenCalledWith([0, 10, 100]); 311 | 312 | // Right to Right 313 | for (let i = 0; i < 99; i += 1) { 314 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[2], { 315 | keyCode: keyCode.DOWN, 316 | }); 317 | } 318 | expect(onChange).toHaveBeenCalledWith([0, 10, 20]); 319 | 320 | // Center to Right 321 | for (let i = 0; i < 99; i += 1) { 322 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 323 | keyCode: keyCode.UP, 324 | }); 325 | } 326 | expect(onChange).toHaveBeenCalledWith([0, 90, 100]); 327 | }); 328 | 329 | describe('should render correctly when allowCross', () => { 330 | function testLTR(name, func) { 331 | it(name, () => { 332 | const onChange = jest.fn(); 333 | const { container, unmount } = render( 334 | , 335 | ); 336 | 337 | // Do move 338 | func(container); 339 | 340 | expect(onChange).toHaveBeenCalledWith([40, 100]); 341 | 342 | unmount(); 343 | }); 344 | } 345 | 346 | testLTR('mouse', (container) => doMouseMove(container, 0, 9999)); 347 | testLTR('touch', (container) => doTouchMove(container, 0, 9999)); 348 | 349 | it('reverse', () => { 350 | const onChange = jest.fn(); 351 | const { container } = render( 352 | , 353 | ); 354 | 355 | // Do move 356 | doMouseMove(container, 0, -10); 357 | 358 | expect(onChange).toHaveBeenCalledWith([30, 40]); 359 | }); 360 | 361 | it('vertical', () => { 362 | const onChange = jest.fn(); 363 | const { container } = render( 364 | , 365 | ); 366 | 367 | // Do move 368 | doMouseMove(container, 0, -10); 369 | 370 | expect(onChange).toHaveBeenCalledWith([30, 40]); 371 | }); 372 | 373 | it('vertical & reverse', () => { 374 | const onChange = jest.fn(); 375 | const { container } = render( 376 | , 377 | ); 378 | 379 | // Do move 380 | doMouseMove(container, 0, -10); 381 | 382 | expect(onChange).toHaveBeenCalledWith([10, 40]); 383 | }); 384 | }); 385 | 386 | describe('should keep pushable with pushable s defalutValue when not allowCross and setState', () => { 387 | function test(name, func) { 388 | it(name, () => { 389 | const onChange = jest.fn(); 390 | 391 | const Demo = () => { 392 | const [value, setValue] = React.useState([20, 40]); 393 | 394 | return ( 395 | { 398 | setValue(values); 399 | onChange(values); 400 | }} 401 | value={value} 402 | allowCross={false} 403 | pushable 404 | /> 405 | ); 406 | }; 407 | 408 | global.error = true; 409 | const { container, unmount } = render(); 410 | 411 | // Do move 412 | func(container); 413 | 414 | expect(onChange).toHaveBeenCalledWith([39, 40]); 415 | 416 | unmount(); 417 | }); 418 | } 419 | 420 | test('mouse', (container) => doMouseMove(container, 0, 9999)); 421 | test('touch', (container) => doTouchMove(container, 0, 9999)); 422 | }); 423 | 424 | describe('track draggable', () => { 425 | function test(name, func) { 426 | it(name, () => { 427 | const onChange = jest.fn(); 428 | 429 | const { container, unmount } = render( 430 | , 431 | ); 432 | 433 | // Do move 434 | func(container); 435 | 436 | expect(onChange).toHaveBeenCalledWith([20, 50]); 437 | 438 | unmount(); 439 | }); 440 | } 441 | 442 | test('mouse', (container) => doMouseMove(container, 0, 20, 'rc-slider-track')); 443 | test('touch', (container) => doTouchMove(container, 0, 20, 'rc-slider-track')); 444 | }); 445 | 446 | it('sets aria-orientation to default on the handle', () => { 447 | const { container } = render(); 448 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 449 | 'aria-orientation', 450 | 'horizontal', 451 | ); 452 | }); 453 | 454 | it('sets aria-orientation to vertical on the handles of vertical Slider', () => { 455 | const { container } = render(); 456 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 457 | 'aria-orientation', 458 | 'vertical', 459 | ); 460 | expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( 461 | 'aria-orientation', 462 | 'vertical', 463 | ); 464 | }); 465 | 466 | it('sets aria-label on the handles', () => { 467 | const { container } = render( 468 | , 469 | ); 470 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 471 | 'aria-label', 472 | 'Some Label', 473 | ); 474 | expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( 475 | 'aria-label', 476 | 'Some other Label', 477 | ); 478 | }); 479 | 480 | it('sets aria-labelledby on the handles', () => { 481 | const { container } = render( 482 | , 483 | ); 484 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 485 | 'aria-labelledby', 486 | 'some_id', 487 | ); 488 | expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( 489 | 'aria-labelledby', 490 | 'some_other_id', 491 | ); 492 | }); 493 | 494 | it('sets aria-valuetext on the handles', () => { 495 | const { container } = render( 496 | `${value} of something`, 503 | (value) => `${value} of something else`, 504 | ]} 505 | />, 506 | ); 507 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 508 | 'aria-valuetext', 509 | '1 of something', 510 | ); 511 | expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( 512 | 'aria-valuetext', 513 | '3 of something else', 514 | ); 515 | }); 516 | 517 | // Corresponds to the issue described in https://github.com/react-component/slider/issues/690. 518 | it('should correctly display a dynamically changed number of handles', () => { 519 | const props = { 520 | range: true, 521 | allowCross: false, 522 | marks: { 523 | 0: { label: '0', style: {} }, 524 | 25: { label: '25', style: {} }, 525 | 50: { label: '50', style: {} }, 526 | 75: { label: '75', style: {} }, 527 | 100: { label: '100', style: {} }, 528 | }, 529 | step: null, 530 | }; 531 | 532 | const { container, rerender } = render(); 533 | 534 | const verifyHandles = (values) => { 535 | // Has the number of handles that we set. 536 | expect(container.getElementsByClassName('rc-slider-handle')).toHaveLength(values.length); 537 | 538 | // Handles have the values that we set. 539 | Array.from(container.getElementsByClassName('rc-slider-handle')).forEach((ele, index) => { 540 | expect(ele).toHaveAttribute('aria-valuenow', values[index].toString()); 541 | }); 542 | }; 543 | 544 | // Assert that handles are correct initially. 545 | verifyHandles([0, 25, 50, 75, 100]); 546 | 547 | // Assert that handles are correct after decreasing their number. 548 | rerender(); 549 | verifyHandles([0, 75, 100]); 550 | 551 | // Assert that handles are correct after increasing their number. 552 | rerender(); 553 | verifyHandles([0, 25, 75, 100]); 554 | }); 555 | 556 | describe('focus & blur', () => { 557 | it('focus()', () => { 558 | const handleFocus = jest.fn(); 559 | const { container } = render(); 560 | container.querySelector('.rc-slider-handle').focus(); 561 | expect(handleFocus).toBeCalled(); 562 | }); 563 | 564 | it('blur()', () => { 565 | const handleBlur = jest.fn(); 566 | const { container } = render(); 567 | container.querySelector('.rc-slider-handle').focus(); 568 | container.querySelector('.rc-slider-handle').blur(); 569 | expect(handleBlur).toHaveBeenCalled(); 570 | }); 571 | }); 572 | 573 | it('warning for `draggableTrack` and `mergedStep=null`', () => { 574 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 575 | 576 | render(); 577 | 578 | expect(errorSpy).toHaveBeenCalledWith( 579 | 'Warning: `draggableTrack` is not supported when `step` is `null`.', 580 | ); 581 | errorSpy.mockRestore(); 582 | }); 583 | 584 | it('Track should have the correct thickness', () => { 585 | const { container } = render( 586 | , 587 | ); 588 | 589 | const { container: containerVertical } = render( 590 | , 598 | ); 599 | expect(container.querySelector('.rc-slider-track-draggable')).toBeTruthy(); 600 | expect(containerVertical.querySelector('.rc-slider-track-draggable')).toBeTruthy(); 601 | }); 602 | 603 | it('styles', () => { 604 | const { container } = render( 605 | , 615 | ); 616 | 617 | expect(container.querySelector('.rc-slider-tracks')).toHaveStyle({ 618 | backgroundColor: '#654321', 619 | }); 620 | expect(container.querySelector('.rc-slider-track')).toHaveStyle({ 621 | backgroundColor: '#123456', 622 | }); 623 | expect(container.querySelector('.rc-slider-handle')).toHaveStyle({ 624 | backgroundColor: '#112233', 625 | }); 626 | expect(container.querySelector('.rc-slider-rail')).toHaveStyle({ 627 | backgroundColor: '#332211', 628 | }); 629 | }); 630 | 631 | it('classNames', () => { 632 | const { container } = render( 633 | , 643 | ); 644 | 645 | expect(container.querySelector('.rc-slider-tracks')).toHaveClass('my-tracks'); 646 | expect(container.querySelector('.rc-slider-track')).toHaveClass('my-track'); 647 | expect(container.querySelector('.rc-slider-handle')).toHaveClass('my-handle'); 648 | expect(container.querySelector('.rc-slider-rail')).toHaveClass('my-rail'); 649 | }); 650 | 651 | describe('editable', () => { 652 | it('click to create', () => { 653 | const onChange = jest.fn(); 654 | const { container } = render( 655 | , 662 | ); 663 | 664 | doMouseDown(container, 50, 'rc-slider', true); 665 | 666 | expect(onChange).toHaveBeenCalledWith([0, 50, 100]); 667 | }); 668 | 669 | it('can not editable with draggableTrack at same time', () => { 670 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 671 | render(); 672 | 673 | expect(errorSpy).toHaveBeenCalledWith( 674 | 'Warning: `editable` can not work with `draggableTrack`.', 675 | ); 676 | errorSpy.mockRestore(); 677 | }); 678 | 679 | describe('drag out to remove', () => { 680 | it('uncontrolled', () => { 681 | const onChange = jest.fn(); 682 | const onChangeComplete = jest.fn(); 683 | const { container } = render( 684 | , 692 | ); 693 | 694 | doMouseMove(container, 0, 1000); 695 | expect(onChange).toHaveBeenCalledWith([50, 100]); 696 | 697 | expect(container.querySelectorAll('.rc-slider-track')).toHaveLength(1); 698 | 699 | // Fire mouse up 700 | fireEvent.mouseUp(container.querySelector('.rc-slider-handle')); 701 | expect(onChangeComplete).toHaveBeenCalledWith([50, 100]); 702 | }); 703 | 704 | it('out and back', () => { 705 | const onChange = jest.fn(); 706 | const onChangeComplete = jest.fn(); 707 | const { container } = render( 708 | , 716 | ); 717 | 718 | doMouseMove(container, 0, 1000); 719 | expect(onChange).toHaveBeenCalledWith([50]); 720 | 721 | doMouseDrag(0); 722 | expect(onChange).toHaveBeenCalledWith([0, 50]); 723 | 724 | // Fire mouse up 725 | fireEvent.mouseUp(container.querySelector('.rc-slider-handle')); 726 | expect(onChangeComplete).toHaveBeenCalledWith([0, 50]); 727 | }); 728 | 729 | it('controlled', () => { 730 | const onChange = jest.fn(); 731 | const onChangeComplete = jest.fn(); 732 | 733 | const Demo = () => { 734 | const [value, setValue] = React.useState([0, 50, 100]); 735 | return ( 736 | { 738 | onChange(nextValue); 739 | setValue(nextValue); 740 | }} 741 | onChangeComplete={onChangeComplete} 742 | min={0} 743 | max={100} 744 | value={value} 745 | range={{ editable: true }} 746 | /> 747 | ); 748 | }; 749 | 750 | const { container } = render(); 751 | 752 | doMouseMove(container, 0, 1000); 753 | expect(onChange).toHaveBeenCalledWith([50, 100]); 754 | 755 | // Fire mouse up 756 | fireEvent.mouseUp(container.querySelector('.rc-slider-handle')); 757 | expect(onChangeComplete).toHaveBeenCalledWith([50, 100]); 758 | }); 759 | }); 760 | 761 | it('key to delete', () => { 762 | const onChange = jest.fn(); 763 | 764 | const { container } = render( 765 | ori} 773 | />, 774 | ); 775 | 776 | const handle = container.querySelectorAll('.rc-slider-handle')[1]; 777 | 778 | fireEvent.mouseEnter(handle); 779 | fireEvent.keyDown(handle, { 780 | keyCode: keyCode.DELETE, 781 | }); 782 | 783 | expect(onChange).toHaveBeenCalledWith([0, 100]); 784 | 785 | // Clear all 786 | fireEvent.keyDown(container.querySelector('.rc-slider-handle'), { 787 | keyCode: keyCode.DELETE, 788 | }); 789 | fireEvent.keyDown(container.querySelector('.rc-slider-handle'), { 790 | keyCode: keyCode.DELETE, 791 | }); 792 | expect(onChange).toHaveBeenCalledWith([]); 793 | 794 | // 2 handle 795 | expect(container.querySelectorAll('.rc-slider-handle')).toHaveLength(0); 796 | }); 797 | 798 | it('not remove when minCount', () => { 799 | const onChange = jest.fn(); 800 | 801 | const { container } = render( 802 | ori} 809 | />, 810 | ); 811 | 812 | const handle = container.querySelector('.rc-slider-handle'); 813 | 814 | // Key 815 | fireEvent.mouseEnter(handle); 816 | fireEvent.keyDown(handle, { 817 | keyCode: keyCode.DELETE, 818 | }); 819 | expect(onChange).not.toHaveBeenCalled(); 820 | 821 | // Mouse 822 | doMouseMove(container, 0, 1000); 823 | expect(onChange).toHaveBeenCalledWith([100]); 824 | }); 825 | 826 | it('maxCount not add', () => { 827 | const onChange = jest.fn(); 828 | const { container } = render( 829 | , 836 | ); 837 | 838 | doMouseDown(container, 50, 'rc-slider', true); 839 | expect(onChange).toHaveBeenCalledWith([0, 50]); 840 | }); 841 | }); 842 | }); 843 | -------------------------------------------------------------------------------- /tests/Slider.test.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { createEvent, fireEvent, render } from '@testing-library/react'; 3 | import classNames from 'classnames'; 4 | import keyCode from 'rc-util/lib/KeyCode'; 5 | import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; 6 | import React from 'react'; 7 | import Slider from '../src/Slider'; 8 | 9 | describe('Slider', () => { 10 | beforeAll(() => { 11 | spyElementPrototypes(HTMLElement, { 12 | getBoundingClientRect: () => ({ 13 | top: 0, 14 | bottom: 100, 15 | left: 0, 16 | right: 100, 17 | width: 100, 18 | height: 100, 19 | }), 20 | }); 21 | }); 22 | 23 | it('should render Slider with correct DOM structure', () => { 24 | const { asFragment } = render(); 25 | expect(asFragment().firstChild).toMatchSnapshot(); 26 | }); 27 | 28 | it('should render Slider with value correctly', () => { 29 | const { container } = render(); 30 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ left: '50%' }); 31 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ 32 | left: '0%', 33 | width: '50%', 34 | }); 35 | }); 36 | 37 | it('should render Slider correctly where value > startPoint', () => { 38 | const { container } = render(); 39 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ left: '50%' }); 40 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ 41 | left: '20%', 42 | width: '30%', 43 | }); 44 | }); 45 | 46 | it('should render Slider correctly where value < startPoint', () => { 47 | const { container } = render(); 48 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ left: '40%' }); 49 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ 50 | left: '40%', 51 | width: '20%', 52 | }); 53 | }); 54 | 55 | it('should render reverse Slider with value correctly', () => { 56 | const { container } = render(); 57 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ right: '50%' }); 58 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ 59 | right: '0%', 60 | width: '50%', 61 | }); 62 | }); 63 | 64 | it('should render reverse Slider correctly where value > startPoint', () => { 65 | const { container } = render(); 66 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ right: '50%' }); 67 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ 68 | right: '20%', 69 | width: '30%', 70 | }); 71 | }); 72 | 73 | it('should render reverse Slider correctly where value < startPoint', () => { 74 | const { container } = render(); 75 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ right: '30%' }); 76 | expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ 77 | right: '30%', 78 | width: '20%', 79 | }); 80 | }); 81 | 82 | it('should render reverse Slider with marks correctly', () => { 83 | const marks = { 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10' }; 84 | const { container } = render(); 85 | expect(container.getElementsByClassName('rc-slider-mark-text')[0]).toHaveStyle({ right: '0%' }); 86 | }); 87 | 88 | it('should render Slider without handle if value is null', () => { 89 | const { asFragment } = render(); 90 | expect(asFragment().firstChild).toMatchSnapshot(); 91 | }); 92 | 93 | it('should allow tabIndex to be set on Handle via Slider', () => { 94 | const { container } = render(); 95 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 96 | 'tabIndex', 97 | '1', 98 | ); 99 | }); 100 | 101 | it('should allow tabIndex to be set on Handle via Slider and be equal null', () => { 102 | const { container } = render(); 103 | expect(container.getElementsByClassName('rc-slider-handle')[0]).not.toHaveAttribute('tabIndex'); 104 | }); 105 | 106 | it('increases the value when key "up" is pressed', () => { 107 | const onChange = jest.fn(); 108 | const { container } = render(); 109 | 110 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 111 | keyCode: keyCode.UP, 112 | }); 113 | 114 | expect(onChange).toHaveBeenCalledWith(51); 115 | }); 116 | 117 | it('decreases the value for reverse-vertical when key "up" is pressed', () => { 118 | const onChange = jest.fn(); 119 | const { container } = render(); 120 | 121 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 122 | keyCode: keyCode.UP, 123 | }); 124 | 125 | expect(onChange).toHaveBeenCalledWith(49); 126 | }); 127 | 128 | it('increases the value when key "right" is pressed', () => { 129 | const onChange = jest.fn(); 130 | const { container } = render(); 131 | 132 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 133 | keyCode: keyCode.RIGHT, 134 | }); 135 | 136 | expect(onChange).toHaveBeenCalledWith(51); 137 | }); 138 | 139 | it('it should trigger onAfterChange when key pressed', () => { 140 | const onAfterChange = jest.fn(); 141 | const { container } = render(); 142 | 143 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 144 | keyCode: keyCode.RIGHT, 145 | }); 146 | 147 | expect(onAfterChange).not.toHaveBeenCalled(); 148 | 149 | fireEvent.keyUp(container.getElementsByClassName('rc-slider-handle')[0], { 150 | keyCode: keyCode.RIGHT, 151 | }); 152 | 153 | expect(onAfterChange).toHaveBeenCalled(); 154 | }); 155 | 156 | it('decreases the value for reverse-horizontal when key "right" is pressed', () => { 157 | const onChange = jest.fn(); 158 | const { container } = render(); 159 | 160 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 161 | keyCode: keyCode.RIGHT, 162 | }); 163 | 164 | expect(onChange).toHaveBeenCalledWith(49); 165 | }); 166 | 167 | it('increases the value when key "page up" is pressed, by a factor 2', () => { 168 | const onChange = jest.fn(); 169 | const { container } = render(); 170 | 171 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 172 | keyCode: keyCode.PAGE_UP, 173 | }); 174 | 175 | expect(onChange).toHaveBeenCalledWith(52); 176 | }); 177 | 178 | it('decreases the value when key "down" is pressed', () => { 179 | const onChange = jest.fn(); 180 | const { container } = render(); 181 | 182 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 183 | keyCode: keyCode.DOWN, 184 | }); 185 | 186 | expect(onChange).toHaveBeenCalledWith(49); 187 | }); 188 | 189 | it('decreases the value when key "left" is pressed', () => { 190 | const onChange = jest.fn(); 191 | const onChangeComplete = jest.fn(); 192 | const { container } = render( 193 | , 194 | ); 195 | 196 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 197 | keyCode: keyCode.LEFT, 198 | }); 199 | 200 | expect(onChange).toHaveBeenCalledWith(49); 201 | expect(onChangeComplete).not.toHaveBeenCalled(); 202 | 203 | fireEvent.keyUp(container.getElementsByClassName('rc-slider-handle')[0], { 204 | keyCode: keyCode.LEFT, 205 | }); 206 | 207 | expect(onChangeComplete).toHaveBeenCalled(); 208 | }); 209 | 210 | it('it should work fine when arrow key is pressed', () => { 211 | const onChange = jest.fn(); 212 | const { container } = render(); 213 | 214 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 215 | keyCode: keyCode.LEFT, 216 | }); 217 | expect(onChange).toHaveBeenCalledWith([20, 49]); 218 | 219 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 220 | keyCode: keyCode.RIGHT, 221 | }); 222 | expect(onChange).toHaveBeenCalledWith([20, 50]); 223 | 224 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 225 | keyCode: keyCode.UP, 226 | }); 227 | expect(onChange).toHaveBeenCalledWith([20, 51]); 228 | 229 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { 230 | keyCode: keyCode.DOWN, 231 | }); 232 | expect(onChange).toHaveBeenCalledWith([20, 50]); 233 | }); 234 | 235 | it('decreases the value when key "page down" is pressed, by a factor 2', () => { 236 | const onChange = jest.fn(); 237 | const { container } = render(); 238 | 239 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 240 | keyCode: keyCode.PAGE_DOWN, 241 | }); 242 | 243 | expect(onChange).toHaveBeenCalledWith(48); 244 | }); 245 | 246 | it('sets the value to minimum when key "home" is pressed', () => { 247 | const onChange = jest.fn(); 248 | const { container } = render(); 249 | 250 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 251 | keyCode: keyCode.HOME, 252 | }); 253 | 254 | expect(onChange).toHaveBeenCalledWith(0); 255 | }); 256 | 257 | it('sets the value to maximum when the key "end" is pressed', () => { 258 | const onChange = jest.fn(); 259 | const { container } = render(); 260 | 261 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 262 | keyCode: keyCode.END, 263 | }); 264 | 265 | expect(onChange).toHaveBeenCalledWith(100); 266 | }); 267 | 268 | describe('when component has fixed values', () => { 269 | it('increases the value when key "up" is pressed', () => { 270 | const onChange = jest.fn(); 271 | const { container } = render( 272 | , 279 | ); 280 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 281 | keyCode: keyCode.UP, 282 | }); 283 | expect(onChange).toHaveBeenCalledWith(100); 284 | }); 285 | 286 | it('increases the value when key "right" is pressed', () => { 287 | const onChange = jest.fn(); 288 | const { container } = render( 289 | , 296 | ); 297 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 298 | keyCode: keyCode.RIGHT, 299 | }); 300 | expect(onChange).toHaveBeenCalledWith(100); 301 | }); 302 | 303 | it('decreases the value when key "down" is pressed', () => { 304 | const onChange = jest.fn(); 305 | const { container } = render( 306 | , 313 | ); 314 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 315 | keyCode: keyCode.DOWN, 316 | }); 317 | expect(onChange).toHaveBeenCalledWith(20); 318 | }); 319 | 320 | it('decreases the value when key "left" is pressed', () => { 321 | const onChange = jest.fn(); 322 | const { container } = render( 323 | , 330 | ); 331 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 332 | keyCode: keyCode.LEFT, 333 | }); 334 | expect(onChange).toHaveBeenCalledWith(20); 335 | }); 336 | 337 | it('sets the value to minimum when key "home" is pressed', () => { 338 | const onChange = jest.fn(); 339 | const { container } = render( 340 | , 347 | ); 348 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 349 | keyCode: keyCode.HOME, 350 | }); 351 | expect(onChange).toHaveBeenCalledWith(20); 352 | }); 353 | 354 | it('sets the value to maximum when the key "end" is pressed', () => { 355 | const onChange = jest.fn(); 356 | const { container } = render( 357 | , 364 | ); 365 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 366 | keyCode: keyCode.END, 367 | }); 368 | expect(onChange).toHaveBeenCalledWith(100); 369 | }); 370 | }); 371 | 372 | it('keyboard mix with step & marks', () => { 373 | const onChange = jest.fn(); 374 | 375 | // [0], 3, 7, 10 376 | const { container } = render( 377 | , 385 | ); 386 | const handler = container.getElementsByClassName('rc-slider-handle')[0]; 387 | 388 | // 0, [3], 7, 10 389 | fireEvent.keyDown(handler, { keyCode: keyCode.UP }); 390 | expect(onChange).toHaveBeenCalledWith(3); 391 | 392 | // 0, 3, [7], 10 393 | onChange.mockReset(); 394 | fireEvent.keyDown(handler, { keyCode: keyCode.UP }); 395 | expect(onChange).toHaveBeenCalledWith(7); 396 | 397 | // 0, 3, 7, [10] 398 | onChange.mockReset(); 399 | fireEvent.keyDown(handler, { keyCode: keyCode.UP }); 400 | expect(onChange).toHaveBeenCalledWith(10); 401 | 402 | // 0, 3, 7, [10] 403 | onChange.mockReset(); 404 | fireEvent.keyDown(handler, { keyCode: keyCode.UP }); 405 | expect(onChange).not.toHaveBeenCalled(); 406 | 407 | // 0, 3, [7], 10 408 | onChange.mockReset(); 409 | fireEvent.keyDown(handler, { keyCode: keyCode.DOWN }); 410 | expect(onChange).toHaveBeenCalledWith(7); 411 | 412 | // 0, [3], 7, 10 413 | onChange.mockReset(); 414 | fireEvent.keyDown(handler, { keyCode: keyCode.DOWN }); 415 | expect(onChange).toHaveBeenCalledWith(3); 416 | 417 | // [0], 3, 7, 10 418 | onChange.mockReset(); 419 | fireEvent.keyDown(handler, { keyCode: keyCode.DOWN }); 420 | expect(onChange).toHaveBeenCalledWith(0); 421 | 422 | // [0], 3, 7, 10 423 | onChange.mockReset(); 424 | fireEvent.keyDown(handler, { keyCode: keyCode.DOWN }); 425 | expect(onChange).not.toHaveBeenCalled(); 426 | }); 427 | 428 | it('sets aria-label on the handle', () => { 429 | const { container } = render(); 430 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 431 | 'aria-label', 432 | 'Some Label', 433 | ); 434 | }); 435 | 436 | it('sets aria-labelledby on the handle', () => { 437 | const { container } = render(); 438 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 439 | 'aria-labelledby', 440 | 'some_id', 441 | ); 442 | }); 443 | 444 | it('sets aria-required on the handle', () => { 445 | const { container } = render(); 446 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 447 | 'aria-required', 448 | 'true', 449 | ); 450 | }); 451 | 452 | it('sets aria-valuetext on the handle', () => { 453 | const { container } = render( 454 | `${value} of something`} 459 | />, 460 | ); 461 | const handle = container.getElementsByClassName('rc-slider-handle')[0]; 462 | expect(handle).toHaveAttribute('aria-valuetext', '3 of something'); 463 | 464 | fireEvent.keyDown(handle, { keyCode: keyCode.RIGHT }); 465 | expect(handle).toHaveAttribute('aria-valuetext', '4 of something'); 466 | }); 467 | 468 | describe('focus & blur', () => { 469 | it('focus', () => { 470 | const handleFocus = jest.fn(); 471 | const { container, unmount } = render( 472 | , 473 | ); 474 | container.getElementsByClassName('rc-slider-handle')[0].focus(); 475 | expect(handleFocus).toBeCalled(); 476 | 477 | unmount(); 478 | }); 479 | 480 | it('blur', () => { 481 | const handleBlur = jest.fn(); 482 | const { container, unmount } = render( 483 | , 484 | ); 485 | container.getElementsByClassName('rc-slider-handle')[0].focus(); 486 | container.getElementsByClassName('rc-slider-handle')[0].blur(); 487 | expect(handleBlur).toBeCalled(); 488 | 489 | unmount(); 490 | }); 491 | 492 | it('ref focus & blur', () => { 493 | const onFocus = jest.fn(); 494 | const onBlur = jest.fn(); 495 | const ref = React.createRef(); 496 | render(); 497 | 498 | ref.current.focus(); 499 | expect(onFocus).toBeCalled(); 500 | 501 | ref.current.blur(); 502 | expect(onBlur).toBeCalled(); 503 | }); 504 | }); 505 | 506 | it('should not be out of range when value is null', () => { 507 | const { container, rerender } = render(); 508 | expect(container.getElementsByClassName('rc-slider-track')).toHaveLength(0); 509 | 510 | rerender(); 511 | expect(container.getElementsByClassName('rc-slider-track')).toHaveLength(1); 512 | }); 513 | 514 | describe('click slider to change value', () => { 515 | it('ltr', () => { 516 | const onChange = jest.fn(); 517 | const { container } = render(); 518 | fireEvent.mouseDown(container.querySelector('.rc-slider'), { 519 | clientX: 20, 520 | }); 521 | 522 | expect(onChange).toHaveBeenCalledWith(20); 523 | }); 524 | 525 | it('rtl', () => { 526 | const onChange = jest.fn(); 527 | const { container } = render(); 528 | fireEvent.mouseDown(container.querySelector('.rc-slider'), { 529 | clientX: 20, 530 | }); 531 | 532 | expect(onChange).toHaveBeenCalledWith(80); 533 | }); 534 | 535 | it('btt', () => { 536 | const onChange = jest.fn(); 537 | const { container } = render(); 538 | fireEvent.mouseDown(container.querySelector('.rc-slider'), { 539 | clientY: 93, 540 | }); 541 | 542 | expect(onChange).toHaveBeenCalledWith(7); 543 | }); 544 | 545 | it('ttb', () => { 546 | const onChange = jest.fn(); 547 | const { container } = render(); 548 | fireEvent.mouseDown(container.querySelector('.rc-slider'), { 549 | clientY: 93, 550 | }); 551 | 552 | expect(onChange).toHaveBeenCalledWith(93); 553 | }); 554 | 555 | it('null value click to become 2 values', () => { 556 | const onChange = jest.fn(); 557 | const { container } = render(); 558 | fireEvent.mouseDown(container.querySelector('.rc-slider'), { 559 | clientX: 20, 560 | }); 561 | 562 | expect(onChange).toHaveBeenCalledWith([20, 20]); 563 | }); 564 | 565 | it('should call onBeforeChange, onChange, and onAfterChange', () => { 566 | const onBeforeChange = jest.fn(); 567 | const onChange = jest.fn(); 568 | const onAfterChange = jest.fn(); 569 | const { container } = render( 570 | , 575 | ); 576 | fireEvent.mouseDown(container.querySelector('.rc-slider'), { 577 | clientX: 20, 578 | }); 579 | 580 | expect(onBeforeChange).toHaveBeenCalledWith(20); 581 | expect(onChange).toHaveBeenCalledWith(20); 582 | expect(onAfterChange).not.toHaveBeenCalled(); 583 | fireEvent.mouseUp(container.querySelector('.rc-slider'), { 584 | clientX: 20, 585 | }); 586 | expect(onAfterChange).toHaveBeenCalledWith(20); 587 | }); 588 | }); 589 | 590 | it('autoFocus', () => { 591 | const onFocus = jest.fn(); 592 | render(); 593 | 594 | expect(onFocus).toHaveBeenCalled(); 595 | }); 596 | 597 | it('custom handle', () => { 598 | const { container } = render( 599 | 601 | React.cloneElement(node, { 602 | className: classNames(node.props.className, 'custom-handle'), 603 | }) 604 | } 605 | />, 606 | ); 607 | 608 | expect(container.querySelector('.custom-handle')).toBeTruthy(); 609 | }); 610 | 611 | // https://github.com/ant-design/ant-design/issues/34020 612 | it('max value not align with step', () => { 613 | const onChange = jest.fn(); 614 | const { container } = render( 615 | , 616 | ); 617 | fireEvent.keyDown(container.querySelector('.rc-slider-handle'), { keyCode: keyCode.RIGHT }); 618 | 619 | expect(onChange).toHaveBeenCalledWith(2); 620 | expect(container.querySelector('.rc-slider-handle').style.left).toBe('100%'); 621 | }); 622 | 623 | it('not show decimal', () => { 624 | const onChange = jest.fn(); 625 | const { container } = render( 626 | , 627 | ); 628 | fireEvent.keyDown(container.querySelector('.rc-slider-handle'), { keyCode: keyCode.RIGHT }); 629 | expect(onChange).toHaveBeenCalledWith(0.82); 630 | }); 631 | 632 | it('onAfterChange should return number', () => { 633 | const onAfterChange = jest.fn(); 634 | const { container } = render(); 635 | fireEvent.mouseDown(container.querySelector('.rc-slider'), { 636 | clientX: 20, 637 | }); 638 | expect(onAfterChange).not.toHaveBeenCalled(); 639 | fireEvent.mouseUp(container.querySelector('.rc-slider'), { 640 | clientX: 20, 641 | }); 642 | expect(onAfterChange).toHaveBeenCalledWith(20); 643 | }); 644 | 645 | // https://github.com/react-component/slider/pull/948 646 | it('could drag handler after click tracker', () => { 647 | const onChange = jest.fn(); 648 | const { container } = render(); 649 | fireEvent.mouseDown(container.querySelector('.rc-slider'), { 650 | clientX: 20, 651 | }); 652 | expect(onChange).toHaveBeenLastCalledWith(20); 653 | 654 | // Drag 655 | const mouseMove = createEvent.mouseMove(document); 656 | mouseMove.pageX = 100; 657 | fireEvent(document, mouseMove); 658 | expect(onChange).toHaveBeenLastCalledWith(100); 659 | }); 660 | 661 | it('should render Slider with included=false', () => { 662 | const { asFragment } = render(); 663 | expect(asFragment().firstChild).toMatchSnapshot(); 664 | }); 665 | 666 | it('tipFormatter should not crash with undefined value', () => { 667 | [undefined, null].forEach((value) => { 668 | render(); 669 | }); 670 | }); 671 | }); 672 | -------------------------------------------------------------------------------- /tests/Tooltip.test.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import React from 'react'; 4 | import Slider from '../src/Slider'; 5 | 6 | describe('Slider.Tooltip', () => { 7 | it('internal activeHandleRender support', () => { 8 | const { container } = render( 9 | 13 | React.cloneElement(node, { 14 | 'data-test': 'bamboo', 15 | 'data-value': info.value, 16 | }) 17 | } 18 | />, 19 | ); 20 | 21 | // Click second 22 | fireEvent.mouseEnter(container.querySelectorAll('.rc-slider-handle')[1]); 23 | expect(container.querySelector('.rc-slider-handle[data-test]')).toBeTruthy(); 24 | expect( 25 | container.querySelector('.rc-slider-handle[data-value]').getAttribute('data-value'), 26 | ).toBe('50'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/__mocks__/rc-trigger.js: -------------------------------------------------------------------------------- 1 | import Trigger from 'rc-trigger/lib/mock'; 2 | 3 | export default Trigger; 4 | -------------------------------------------------------------------------------- /tests/__snapshots__/Range.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Range should render Multi-Range with correct DOM structure 1`] = ` 4 |
7 |
10 |
14 |
18 |
22 |
25 |
36 |
47 |
58 |
69 |
70 | `; 71 | 72 | exports[`Range should render Range with correct DOM structure 1`] = ` 73 |
76 |
79 |
83 |
86 |
97 |
108 |
109 | `; 110 | -------------------------------------------------------------------------------- /tests/__snapshots__/Slider.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Slider should render Slider with correct DOM structure 1`] = ` 4 |
7 |
10 |
14 |
17 |
28 |
29 | `; 30 | 31 | exports[`Slider should render Slider with included=false 1`] = ` 32 |
35 |
38 |
41 |
52 |
53 | `; 54 | 55 | exports[`Slider should render Slider without handle if value is null 1`] = ` 56 |
59 |
62 |
65 |
66 | `; 67 | -------------------------------------------------------------------------------- /tests/common.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len, no-undef */ 2 | import '@testing-library/jest-dom'; 3 | import { createEvent, fireEvent, render } from '@testing-library/react'; 4 | import KeyCode from 'rc-util/lib/KeyCode'; 5 | import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; 6 | import React from 'react'; 7 | import Slider from '../src'; 8 | 9 | // const setWidth = (object, width) => { 10 | // // https://github.com/tmpvar/jsdom/commit/0cdb2efcc69b6672dc2928644fc0172df5521176 11 | // Object.defineProperty(object, 'getBoundingClientRect', { 12 | // value: () => ({ 13 | // width, 14 | // // Let all other values retain the JSDom default of `0`. 15 | // bottom: 0, 16 | // height: 0, 17 | // left: 0, 18 | // right: 0, 19 | // top: 0, 20 | // }), 21 | // enumerable: true, 22 | // configurable: true, 23 | // }); 24 | // }; 25 | 26 | describe('Common', () => { 27 | beforeAll(() => { 28 | spyElementPrototypes(HTMLElement, { 29 | getBoundingClientRect: () => ({ 30 | width: 100, 31 | height: 100, 32 | }), 33 | }); 34 | }); 35 | 36 | it('should render vertical Slider/Range, when `vertical` is true', () => { 37 | const { container: container1 } = render(); 38 | expect(container1.getElementsByClassName('rc-slider-vertical')).toHaveLength(1); 39 | 40 | const { container: container2 } = render(); 41 | expect(container2.getElementsByClassName('rc-slider-vertical')).toHaveLength(1); 42 | }); 43 | 44 | it('should render dots correctly when `dots=true`', () => { 45 | const { container: container1 } = render(); 46 | expect(container1.getElementsByClassName('rc-slider-dot')).toHaveLength(11); 47 | expect(container1.getElementsByClassName('rc-slider-dot-active')).toHaveLength(6); 48 | 49 | const { container: container2 } = render(); 50 | expect(container2.getElementsByClassName('rc-slider-dot')).toHaveLength(11); 51 | expect(container2.getElementsByClassName('rc-slider-dot-active')).toHaveLength(4); 52 | }); 53 | 54 | it('should render normally when `dots=true` and `step=null`', () => { 55 | const { container } = render(); 56 | expect(() => container).not.toThrowError(); 57 | }); 58 | 59 | it('should render dots correctly when dotStyle is dynamic`', () => { 60 | const { container: container1 } = render( 61 | ({ width: `${dotValue}px` })} />, 62 | ); 63 | expect(container1.getElementsByClassName('rc-slider-dot')[1]).toHaveStyle( 64 | 'left: 10%; transform: translateX(-50%); width: 10px', 65 | ); 66 | expect(container1.getElementsByClassName('rc-slider-dot')[2]).toHaveStyle( 67 | 'left: 20%; transform: translateX(-50%); width: 20px', 68 | ); 69 | 70 | const { container: container2 } = render( 71 | ({ width: `${dotValue}px` })} 77 | />, 78 | ); 79 | expect(container2.getElementsByClassName('rc-slider-dot-active')[1]).toHaveStyle( 80 | 'left: 30%; transform: translateX(-50%); width: 30px', 81 | ); 82 | expect(container2.getElementsByClassName('rc-slider-dot-active')[2]).toHaveStyle( 83 | 'left: 40%; transform: translateX(-50%); width: 40px', 84 | ); 85 | }); 86 | 87 | it('should not set value greater than `max` or smaller `min`', () => { 88 | const { container: container1 } = render(); 89 | expect( 90 | container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 91 | ).toBe('10'); 92 | 93 | const { container: container2 } = render(); 94 | expect( 95 | container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 96 | ).toBe('90'); 97 | 98 | const { container: container3 } = render(); 99 | expect( 100 | container3.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 101 | ).toBe('10'); 102 | expect( 103 | container3.getElementsByClassName('rc-slider-handle')[1].getAttribute('aria-valuenow'), 104 | ).toBe('90'); 105 | }); 106 | 107 | it('should not set values when sending invalid numbers', () => { 108 | const { container: container1 } = render(); 109 | expect( 110 | container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 111 | ).toBe('0'); 112 | 113 | const { container: container2 } = render(); 114 | expect( 115 | container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 116 | ).toBe('100'); 117 | 118 | const { container: container3 } = render( 119 | , 120 | ); 121 | expect( 122 | container3.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 123 | ).toBe('0'); 124 | expect( 125 | container3.getElementsByClassName('rc-slider-handle')[1].getAttribute('aria-valuenow'), 126 | ).toBe('100'); 127 | }); 128 | 129 | it('should update value when it is out of range', () => { 130 | const sliderOnChange = jest.fn(); 131 | const { container: container1, rerender: rerender1 } = render( 132 | , 133 | ); 134 | rerender1(); 135 | expect( 136 | container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 137 | ).toBe('10'); 138 | 139 | const rangeOnChange = jest.fn(); 140 | const { container: container2, rerender: rerender2 } = render( 141 | , 142 | ); 143 | rerender2(); 144 | expect( 145 | container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 146 | ).toBe('10'); 147 | }); 148 | 149 | it('should not trigger onChange when no min and max', () => { 150 | const sliderOnChange = jest.fn(); 151 | const { container: container1, rerender: rerender1 } = render( 152 | , 153 | ); 154 | rerender1(); 155 | expect( 156 | container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 157 | ).toBe('100'); 158 | expect(sliderOnChange).not.toHaveBeenCalled(); 159 | 160 | const rangeOnChange = jest.fn(); 161 | const { container: container2, rerender: rerender2 } = render( 162 | , 163 | ); 164 | rerender2(); 165 | expect( 166 | container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 167 | ).toBe('0'); 168 | expect( 169 | container2.getElementsByClassName('rc-slider-handle')[1].getAttribute('aria-valuenow'), 170 | ).toBe('100'); 171 | expect(rangeOnChange).not.toHaveBeenCalled(); 172 | }); 173 | 174 | it('should not trigger onChange when value is out of range', () => { 175 | const sliderOnChange = jest.fn(); 176 | const { container: container1, rerender: rerender1 } = render( 177 | , 178 | ); 179 | rerender1(); 180 | expect( 181 | container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 182 | ).toBe('10'); 183 | expect(sliderOnChange).not.toHaveBeenCalled(); 184 | 185 | const rangeOnChange = jest.fn(); 186 | const { container: container2, rerender: rerender2 } = render( 187 | , 188 | ); 189 | rerender2(); 190 | expect( 191 | container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), 192 | ).toBe('0'); 193 | expect( 194 | container2.getElementsByClassName('rc-slider-handle')[1].getAttribute('aria-valuenow'), 195 | ).toBe('10'); 196 | expect(rangeOnChange).not.toHaveBeenCalled(); 197 | }); 198 | 199 | it('should not call onChange when value is the same', () => { 200 | const handler = jest.fn(); 201 | 202 | const { container: container1 } = render(); 203 | const handle1 = container1.getElementsByClassName('rc-slider-handle')[0]; 204 | fireEvent.mouseDown(handle1); 205 | fireEvent.mouseMove(handle1); 206 | fireEvent.mouseUp(handle1); 207 | 208 | const { container: container2 } = render(); 209 | const handle2 = container2.getElementsByClassName('rc-slider-handle')[1]; 210 | fireEvent.mouseDown(handle2); 211 | fireEvent.mouseMove(handle2); 212 | fireEvent.mouseUp(handle2); 213 | 214 | expect(handler).not.toHaveBeenCalled(); 215 | }); 216 | 217 | // TODO: should update the following test cases for it should test API instead implementation 218 | // it('should set `dragOffset` to correct value when the left handle is clicked off-center', () => { 219 | // const { container } = render(); 220 | // setWidth(wrapper.instance().sliderRef, 100); 221 | // const leftHandle = wrapper 222 | // .find('.rc-slider-handle') 223 | // .at(1) 224 | // .instance(); 225 | // wrapper.simulate('mousedown', { 226 | // type: 'mousedown', 227 | // target: leftHandle, 228 | // pageX: 5, 229 | // button: 0, 230 | // stopPropagation() {}, 231 | // preventDefault() {}, 232 | // }); 233 | // expect(wrapper.instance().dragOffset).toBe(5); 234 | // }); 235 | 236 | // it('should respect `dragOffset` while dragging the handle via MouseEvents', () => { 237 | // const { container } = render(); 238 | // setWidth(wrapper.instance().sliderRef, 100); 239 | // const leftHandle = wrapper 240 | // .find('.rc-slider-handle') 241 | // .at(1) 242 | // .instance(); 243 | // wrapper.simulate('mousedown', { 244 | // type: 'mousedown', 245 | // target: leftHandle, 246 | // pageX: 5, 247 | // button: 0, 248 | // stopPropagation() {}, 249 | // preventDefault() {}, 250 | // }); 251 | // expect(wrapper.instance().dragOffset).toBe(5); 252 | // wrapper.instance().onMouseMove({ 253 | // // to propagation 254 | // type: 'mousemove', 255 | // target: leftHandle, 256 | // pageX: 14, 257 | // button: 0, 258 | // stopPropagation() {}, 259 | // preventDefault() {}, 260 | // }); 261 | // expect(wrapper.instance().getValue()).toBe(9); 262 | // }); 263 | 264 | it('should not go to right direction when mouse go to the left', () => { 265 | const { container } = render(); 266 | const leftHandle = container.getElementsByClassName('rc-slider-handle')[0]; 267 | 268 | const mouseDown = createEvent.mouseDown(leftHandle); 269 | mouseDown.pageX = 5; 270 | 271 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 272 | 'aria-valuenow', 273 | '0', 274 | ); 275 | 276 | const mouseMove = createEvent.mouseMove(leftHandle); 277 | mouseMove.pageX = 0; 278 | 279 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 280 | 'aria-valuenow', 281 | '0', 282 | ); 283 | }); 284 | 285 | it('should call onAfterChange when clicked on mark label', () => { 286 | const labelId = 'to-be-clicked'; 287 | const marks = { 288 | 0: 'some other label', 289 | 100: some label, 290 | }; 291 | 292 | const sliderOnChange = jest.fn(); 293 | const sliderOnAfterChange = jest.fn(); 294 | const { container } = render( 295 | , 301 | ); 302 | const sliderHandleWrapper = container.querySelector(`#${labelId}`); 303 | fireEvent.mouseDown(sliderHandleWrapper); 304 | // Simulate propagation 305 | fireEvent.mouseDown(container.querySelector('.rc-slider')); 306 | fireEvent.mouseUp(container.querySelector('.rc-slider')); 307 | 308 | fireEvent.click(sliderHandleWrapper); 309 | expect(sliderOnChange).toHaveBeenCalled(); 310 | expect(sliderOnAfterChange).toHaveBeenCalled(); 311 | 312 | const rangeOnAfterChange = jest.fn(); 313 | const { container: container2 } = render( 314 | , 315 | ); 316 | const rangeHandleWrapper = container2.querySelector(`#${labelId}`); 317 | fireEvent.click(rangeHandleWrapper); 318 | // Simulate propagation 319 | fireEvent.mouseDown(container2.querySelector('.rc-slider')); 320 | fireEvent.mouseUp(container2.querySelector('.rc-slider')); 321 | expect(rangeOnAfterChange).toHaveBeenCalled(); 322 | }); 323 | 324 | it('only call onAfterChange once', () => { 325 | const sliderOnChange = jest.fn(); 326 | const sliderOnAfterChange = jest.fn(); 327 | const { container } = render( 328 | , 329 | ); 330 | 331 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 332 | keyCode: KeyCode.UP, 333 | }); 334 | 335 | expect(sliderOnChange).toHaveBeenCalled(); 336 | expect(sliderOnAfterChange).not.toHaveBeenCalled(); 337 | 338 | fireEvent.keyUp(container.getElementsByClassName('rc-slider-handle')[0], { 339 | keyCode: KeyCode.UP, 340 | }); 341 | expect(sliderOnAfterChange).toHaveBeenCalled(); 342 | expect(sliderOnAfterChange).toHaveBeenCalledTimes(1); 343 | }); 344 | 345 | it('deprecate onAfterChange', () => { 346 | const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 347 | const onChangeComplete = jest.fn(); 348 | const onAfterChange = jest.fn(); 349 | const { container } = render( 350 | , 351 | ); 352 | 353 | fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { 354 | keyCode: KeyCode.UP, 355 | }); 356 | 357 | expect(onChangeComplete).not.toHaveBeenCalled(); 358 | expect(onAfterChange).not.toHaveBeenCalled(); 359 | 360 | fireEvent.keyUp(container.getElementsByClassName('rc-slider-handle')[0], { 361 | keyCode: KeyCode.UP, 362 | }); 363 | expect(onChangeComplete).toHaveBeenCalledTimes(1); 364 | expect(onAfterChange).toHaveBeenCalledTimes(1); 365 | expect(errSpy).toHaveBeenCalledWith( 366 | 'Warning: [rc-slider] `onAfterChange` is deprecated. Please use `onChangeComplete` instead.', 367 | ); 368 | errSpy.mockRestore(); 369 | }); 370 | 371 | // Move to antd instead 372 | // it('the tooltip should be attach to the container with the id tooltip', () => { 373 | // const SliderWithTooltip = createSliderWithTooltip(Slider); 374 | // const tooltipPrefixer = { 375 | // prefixCls: 'slider-tooltip', 376 | // }; 377 | // const tooltipParent = document.createElement('div'); 378 | // tooltipParent.setAttribute('id', 'tooltip'); 379 | // const { container } = render( 380 | // document.getElementById('tooltip')} 383 | // />, 384 | // ); 385 | // expect(wrapper.instance().props.getTooltipContainer).toBeTruthy(); 386 | // }); 387 | }); 388 | -------------------------------------------------------------------------------- /tests/marks.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len, no-undef */ 2 | import '@testing-library/jest-dom'; 3 | import { fireEvent, render } from '@testing-library/react'; 4 | import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; 5 | import React from 'react'; 6 | import Slider from '../src'; 7 | 8 | describe('marks', () => { 9 | beforeAll(() => { 10 | spyElementPrototypes(HTMLElement, { 11 | getBoundingClientRect: () => ({ 12 | width: 100, 13 | height: 100, 14 | }), 15 | }); 16 | }); 17 | 18 | it('should render marks correctly when `marks` is not an empty object', () => { 19 | const marks = { 0: 0, 30: '30', 99: '', 100: '100' }; 20 | 21 | const { container } = render(); 22 | expect(container.getElementsByClassName('rc-slider-mark-text')).toHaveLength(3); 23 | expect(container.getElementsByClassName('rc-slider-mark-text')[0].innerHTML).toBe('0'); 24 | expect(container.getElementsByClassName('rc-slider-mark-text')[1].innerHTML).toBe('30'); 25 | expect(container.getElementsByClassName('rc-slider-mark-text')[2].innerHTML).toBe('100'); 26 | 27 | const { container: container2 } = render(); 28 | expect(container2.getElementsByClassName('rc-slider-mark-text')).toHaveLength(3); 29 | expect(container2.getElementsByClassName('rc-slider-mark-text')[0].innerHTML).toBe('0'); 30 | expect(container2.getElementsByClassName('rc-slider-mark-text')[1].innerHTML).toBe('30'); 31 | expect(container2.getElementsByClassName('rc-slider-mark-text')[2].innerHTML).toBe('100'); 32 | 33 | expect(container.querySelector('.rc-slider-with-marks')).toBeTruthy(); 34 | }); 35 | 36 | it('should select correct value while click on marks', () => { 37 | const marks = { 0: '0', 30: '30', 100: '100' }; 38 | const onChange = jest.fn(); 39 | const onChangeComplete = jest.fn(); 40 | const { container } = render(); 41 | fireEvent.click(container.getElementsByClassName('rc-slider-mark-text')[1]); 42 | expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( 43 | 'aria-valuenow', 44 | '30', 45 | ); 46 | expect(onChange).toHaveBeenCalledTimes(1); 47 | expect(onChange).toHaveBeenCalledWith(30); 48 | expect(onChangeComplete).toHaveBeenCalledTimes(1); 49 | expect(onChangeComplete).toHaveBeenCalledWith(30); 50 | }); 51 | 52 | // TODO: not implement yet 53 | // zombieJ: since this test leave years but not implement. Could we remove this? 54 | // xit('should select correct value while click on marks in Ranger', () => { 55 | // const rangeWrapper = render(); 56 | // const rangeMark = rangeWrapper.find('.rc-slider-mark-text').at(1); 57 | // rangeMark.simulate('mousedown', { 58 | // type: 'mousedown', 59 | // target: rangeMark, 60 | // pageX: 25, 61 | // button: 0, 62 | // stopPropagation() {}, 63 | // preventDefault() {}, 64 | // }); 65 | // expect(rangeWrapper.state('bounds')).toBe([0, 30]); 66 | // }); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/slider/874875a809e6d7423449ba8a8277e3f4cb2277cb/tests/setup.js -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "moduleResolution": "node", 5 | "baseUrl": "./", 6 | "jsx": "react", 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "paths": { 11 | "@/*": ["src/*"], 12 | "@@/*": ["src/.umi/*"], 13 | "rc-slider": ["src/index.tsx"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | --------------------------------------------------------------------------------