diff --git a/assets/index.less b/assets/index.less index 922fcc855..c4e418efa 100644 --- a/assets/index.less +++ b/assets/index.less @@ -8,8 +8,8 @@ @tooltip-arrow-width: 4px; @tooltip-distance: @tooltip-arrow-width+4; @tooltip-arrow-color: @tooltip-bg; -@ease-out-quint : cubic-bezier(0.23, 1, 0.32, 1); -@ease-in-quint : cubic-bezier(0.755, 0.05, 0.855, 0.06); +@ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1); +@ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06); .borderBox() { box-sizing: border-box; @@ -23,9 +23,9 @@ .@{prefixClass} { position: relative; + width: 100%; height: 14px; padding: 5px 0; - width: 100%; border-radius: @border-radius-base; touch-action: none; .borderBox(); @@ -33,30 +33,30 @@ &-rail { position: absolute; width: 100%; - background-color: #e9e9e9; height: 4px; + background-color: #e9e9e9; border-radius: @border-radius-base; } &-track { position: absolute; - left: 0; height: 4px; - border-radius: @border-radius-base; background-color: tint(@primary-color, 60%); + border-radius: @border-radius-base; } &-handle { position: absolute; width: 14px; height: 14px; + margin-top: -5px; + background-color: #fff; + border: solid 2px tint(@primary-color, 50%); + border-radius: 50%; cursor: pointer; cursor: -webkit-grab; - margin-top: -5px; cursor: grab; - border-radius: 50%; - border: solid 2px tint(@primary-color, 50%); - background-color: #fff; + opacity: 0.8; touch-action: pan-x; &-dragging&-dragging&-dragging { @@ -66,6 +66,7 @@ &:focus { outline: none; + box-shadow: 0 0 0 3px tint(@primary-color, 50%); } &-click-focused:focus { @@ -96,10 +97,10 @@ &-mark-text { position: absolute; display: inline-block; - vertical-align: middle; + color: #999; text-align: center; + vertical-align: middle; cursor: pointer; - color: #999; &-active { color: #666; @@ -111,19 +112,20 @@ width: 100%; height: 4px; background: transparent; + pointer-events: none; } &-dot { position: absolute; bottom: -2px; - margin-left: -4px; width: 8px; height: 8px; - border: 2px solid #e9e9e9; + // margin-left: -4px; + vertical-align: middle; background-color: #fff; - cursor: pointer; + border: 2px solid #e9e9e9; border-radius: 50%; - vertical-align: middle; + cursor: pointer; &-active { border-color: tint(@primary-color, 50%); } @@ -139,15 +141,17 @@ background-color: @disabledColor; } - .@{prefixClass}-handle, .@{prefixClass}-dot { + .@{prefixClass}-handle, + .@{prefixClass}-dot { + background-color: #fff; border-color: @disabledColor; box-shadow: none; - background-color: #fff; cursor: not-allowed; } - .@{prefixClass}-mark-text, .@{prefixClass}-dot { - cursor: not-allowed!important; + .@{prefixClass}-mark-text, + .@{prefixClass}-dot { + cursor: not-allowed !important; } } } @@ -159,17 +163,18 @@ .@{prefixClass} { &-rail { - height: 100%; width: 4px; + height: 100%; } &-track { - left: 5px; bottom: 0; + left: 5px; width: 4px; } &-handle { + margin-top: 0; margin-left: -5px; touch-action: pan-y; } @@ -181,31 +186,32 @@ } &-step { - height: 100%; width: 4px; + height: 100%; } &-dot { - left: 2px; - margin-bottom: -4px; - &:first-child { - margin-bottom: -4px; - } - &:last-child { - margin-bottom: -4px; - } + margin-left: -2px; + // margin-bottom: -4px; + // &:first-child { + // margin-bottom: -4px; + // } + // &:last-child { + // margin-bottom: -4px; + // } } } } .motion-common() { - animation-duration: .3s; - animation-fill-mode: both; display: block !important; + animation-duration: 0.3s; + animation-fill-mode: both; } .make-motion(@className, @keyframeName) { - .@{className}-enter, .@{className}-appear { + .@{className}-enter, + .@{className}-appear { .motion-common(); animation-play-state: paused; } @@ -213,18 +219,20 @@ .motion-common(); animation-play-state: paused; } - .@{className}-enter.@{className}-enter-active, .@{className}-appear.@{className}-appear-active { - animation-name: ~"@{keyframeName}In"; + .@{className}-enter.@{className}-enter-active, + .@{className}-appear.@{className}-appear-active { + animation-name: ~'@{keyframeName}In'; animation-play-state: running; } .@{className}-leave.@{className}-leave-active { - animation-name: ~"@{keyframeName}Out"; + animation-name: ~'@{keyframeName}Out'; animation-play-state: running; } } .zoom-motion(@className, @keyframeName) { .make-motion(@className, @keyframeName); - .@{className}-enter, .@{className}-appear { + .@{className}-enter, + .@{className}-appear { transform: scale(0, 0); // need this by yiminghe animation-timing-function: @ease-out-quint; } @@ -236,32 +244,32 @@ @keyframes rcSliderTooltipZoomDownIn { 0% { - opacity: 0; - transform-origin: 50% 100%; transform: scale(0, 0); + transform-origin: 50% 100%; + opacity: 0; } 100% { - transform-origin: 50% 100%; transform: scale(1, 1); + transform-origin: 50% 100%; } } @keyframes rcSliderTooltipZoomDownOut { 0% { - transform-origin: 50% 100%; transform: scale(1, 1); + transform-origin: 50% 100%; } 100% { - opacity: 0; - transform-origin: 50% 100%; transform: scale(0, 0); + transform-origin: 50% 100%; + opacity: 0; } } .@{prefixClass}-tooltip { position: absolute; - left: -9999px; top: -9999px; + left: -9999px; visibility: visible; .borderBox(); @@ -275,12 +283,12 @@ } &-inner { - padding: 6px 2px; min-width: 24px; height: 24px; + padding: 6px 2px; + color: @tooltip-color; font-size: 12px; line-height: 1; - color: @tooltip-color; text-align: center; text-decoration: none; background-color: @tooltip-bg; diff --git a/docs/demo/debug.md b/docs/demo/debug.md new file mode 100644 index 000000000..27706ef0f --- /dev/null +++ b/docs/demo/debug.md @@ -0,0 +1,3 @@ +## debug + + diff --git a/docs/demo/handle.md b/docs/demo/handle.md index 55bb4b4c1..5c0b3b37c 100644 --- a/docs/demo/handle.md +++ b/docs/demo/handle.md @@ -1,3 +1,3 @@ ## handle - + diff --git a/docs/demo/marks.md b/docs/demo/marks.md index a39f8646c..9229dd9a5 100644 --- a/docs/demo/marks.md +++ b/docs/demo/marks.md @@ -1,3 +1,3 @@ ## marks - + diff --git a/docs/demo/range.md b/docs/demo/range.md index f3f4f1b2d..ee0985c4e 100644 --- a/docs/demo/range.md +++ b/docs/demo/range.md @@ -1,3 +1,3 @@ ## range - + diff --git a/docs/demo/slider.md b/docs/demo/slider.md index fa5d28d87..a12fc54ba 100644 --- a/docs/demo/slider.md +++ b/docs/demo/slider.md @@ -1,3 +1,3 @@ ## slider - + diff --git a/docs/demo/vertical.md b/docs/demo/vertical.md index 740559bd1..4c8d42174 100644 --- a/docs/demo/vertical.md +++ b/docs/demo/vertical.md @@ -1,3 +1,3 @@ ## vertical - + diff --git a/docs/examples/components/TooltipSlider.tsx b/docs/examples/components/TooltipSlider.tsx new file mode 100644 index 000000000..0067ac689 --- /dev/null +++ b/docs/examples/components/TooltipSlider.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import 'rc-tooltip/assets/bootstrap.css'; +import Slider from 'rc-slider'; +import type { SliderProps } from 'rc-slider'; +import raf from 'rc-util/lib/raf'; +import Tooltip from 'rc-tooltip'; + +const HandleTooltip = (props: { + value: number; + children: React.ReactElement; + visible: boolean; + tipFormatter?: (value: number) => React.ReactNode; +}) => { + const { value, children, visible, tipFormatter = (val) => `${val} %`, ...restProps } = props; + + const tooltipRef = React.useRef(); + const rafRef = React.useRef(null); + + function cancelKeepAlign() { + raf.cancel(rafRef.current!); + } + + function keepAlign() { + rafRef.current = raf(() => { + tooltipRef.current?.forcePopupAlign(); + }); + } + + React.useEffect(() => { + if (visible) { + keepAlign(); + } else { + cancelKeepAlign(); + } + + return cancelKeepAlign; + }, [value, visible]); + + return ( + + {children} + + ); +}; + +export const handleRender: SliderProps['handleRender'] = (node, props) => { + return ( + + {node} + + ); +}; + +const TooltipSlider = ({ + tipFormatter, + tipProps, + ...props +}: SliderProps & { tipFormatter?: (value: number) => React.ReactNode; tipProps: any }) => { + const tipHandleRender: SliderProps['handleRender'] = (node, handleProps) => { + return ( + + {node} + + ); + }; + + return ; +}; + +export default TooltipSlider; diff --git a/docs/examples/debug.tsx b/docs/examples/debug.tsx new file mode 100644 index 000000000..54457f4e1 --- /dev/null +++ b/docs/examples/debug.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import Slider from 'rc-slider'; +import '../../assets/index.less'; + +export default () => { + const [disabled, setDisabled] = React.useState(false); + const [range, setRange] = React.useState(true); + const [reverse, setReverse] = React.useState(false); + const [vertical, setVertical] = React.useState(false); + const [value, setValue] = React.useState(30); + + return ( +
+
+ + + + +
+ +
+ { + // console.log('Change:', nextValues); + // setValue(nextValues as any); + // }} + // value={value} + /> +
+
+ ); +}; diff --git a/docs/examples/handle.jsx b/docs/examples/handle.jsx deleted file mode 100644 index b9b3095f1..000000000 --- a/docs/examples/handle.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import 'rc-tooltip/assets/bootstrap.css'; -import React from 'react'; -import Slider, { SliderTooltip } from 'rc-slider'; -import '../../assets/index.less'; - -const { createSliderWithTooltip } = Slider; -const Range = createSliderWithTooltip(Slider.Range); -const { Handle } = Slider; - -const handle = props => { - const { value, dragging, index, ...restProps } = props; - return ( - - - - ); -}; - -const wrapperStyle = { width: 400, margin: 50 }; - -export default () => ( -
-
-

Slider with custom handle

- -
-
-

Reversed Slider with custom handle

- -
-
-

Slider with fixed values

- -
-
-

Range with custom tooltip

- `${value}%`} /> -
-
-); diff --git a/docs/examples/handle.tsx b/docs/examples/handle.tsx new file mode 100644 index 000000000..dfba92d1a --- /dev/null +++ b/docs/examples/handle.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import Slider from 'rc-slider'; +import '../../assets/index.less'; +import TooltipSlider, { handleRender } from './components/TooltipSlider'; + +const wrapperStyle = { width: 400, margin: 50 }; + +export default () => ( +
+
+

Slider with custom handle

+ +
+
+

Reversed Slider with custom handle

+ +
+
+

Slider with fixed values

+ +
+
+

Range with custom tooltip

+ `${value}!`} + /> +
+
+); diff --git a/docs/examples/marks.jsx b/docs/examples/marks.tsx similarity index 76% rename from docs/examples/marks.jsx rename to docs/examples/marks.tsx index fd1340b57..2b52bf6f1 100644 --- a/docs/examples/marks.jsx +++ b/docs/examples/marks.tsx @@ -28,6 +28,21 @@ export default () => ( +
+

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

+ +
+

Slider with marks and steps

@@ -48,11 +63,11 @@ export default () => (

Range with marks

- +

Range with marks and steps

- +
); diff --git a/docs/examples/range.jsx b/docs/examples/range.tsx similarity index 72% rename from docs/examples/range.jsx rename to docs/examples/range.tsx index 114dbc7e0..c566ffc80 100644 --- a/docs/examples/range.jsx +++ b/docs/examples/range.tsx @@ -3,15 +3,13 @@ import React from 'react'; import Slider from 'rc-slider'; import '../../assets/index.less'; -const { Range } = Slider; - const style = { width: 400, margin: 50 }; function log(value) { console.log(value); //eslint-disable-line } -class CustomizedRange extends React.Component { +class CustomizedRange extends React.Component { constructor(props) { super(props); this.state = { @@ -55,13 +53,13 @@ class CustomizedRange extends React.Component {

- + ); } } -class DynamicBounds extends React.Component { +class DynamicBounds extends React.Component { constructor(props) { super(props); this.state = { @@ -96,7 +94,8 @@ class DynamicBounds extends React.Component {

- { constructor(props) { super(props); this.state = { @@ -122,11 +121,11 @@ class ControlledRange extends React.Component { }; render() { - return ; + return ; } } -class ControlledRangeDisableAcross extends React.Component { +class ControlledRangeDisableAcross extends React.Component { constructor(props) { super(props); this.state = { @@ -142,7 +141,8 @@ class ControlledRangeDisableAcross extends React.Component { render() { return ( - { constructor(props) { super(props); this.state = { @@ -168,7 +168,12 @@ class PureRenderRange extends React.Component { render() { return ( - + ); } } @@ -177,23 +182,23 @@ export default () => (

Basic Range,`allowCross=false`

- +

Basic reverse Range`

- +

Basic Range,`step=20`

- +

Basic Range,`step=20, dots`

- +

Basic Range,disabled

- +

Controlled Range

@@ -208,12 +213,13 @@ export default () => (
-

Multi Range

- +

Multi Range, count=3 and pushable=true

+
-

Multi Range with custom track and handle style

- Multi Range with custom track and handle style and pushable

+ (

draggableTrack two points

- +

draggableTrack two points(reverse)

- +

draggableTrack multiple points

- +
); diff --git a/docs/examples/slider.jsx b/docs/examples/slider.tsx similarity index 86% rename from docs/examples/slider.jsx rename to docs/examples/slider.tsx index 92967569c..855a89633 100644 --- a/docs/examples/slider.jsx +++ b/docs/examples/slider.tsx @@ -1,7 +1,8 @@ /* eslint react/no-multi-comp: 0, max-len: 0 */ import React from 'react'; -import Slider, { createSliderWithTooltip } from 'rc-slider'; +import Slider from 'rc-slider'; import '../../assets/index.less'; +import TooltipSlider from './components/TooltipSlider'; const style = { width: 600, margin: 50 }; @@ -13,9 +14,9 @@ function percentFormatter(v) { return `${v} %`; } -const SliderWithTooltip = createSliderWithTooltip(Slider); +// const SliderWithTooltip = createSliderWithTooltip(Slider); -class NullableSlider extends React.Component { +class NullableSlider extends React.Component { constructor(props) { super(props); this.state = { @@ -23,14 +24,14 @@ class NullableSlider extends React.Component { }; } - onSliderChange = value => { + onSliderChange = (value) => { log(value); this.setState({ value, }); }; - onAfterChange = value => { + onAfterChange = (value) => { console.log(value); //eslint-disable-line }; @@ -55,7 +56,20 @@ class NullableSlider extends React.Component { } } -class CustomizedSlider extends React.Component { +const NullableRangeSlider = () => { + const [value, setValue] = React.useState(null); + + return ( +
+ + +
+ ); +}; + +class CustomizedSlider extends React.Component { constructor(props) { super(props); this.state = { @@ -63,14 +77,14 @@ class CustomizedSlider extends React.Component { }; } - onSliderChange = value => { + onSliderChange = (value) => { log(value); this.setState({ value, }); }; - onAfterChange = value => { + onAfterChange = (value) => { console.log(value); //eslint-disable-line }; @@ -85,7 +99,7 @@ class CustomizedSlider extends React.Component { } } -class DynamicBounds extends React.Component { +class DynamicBounds extends React.Component { constructor(props) { super(props); this.state = { @@ -96,24 +110,24 @@ class DynamicBounds extends React.Component { }; } - onSliderChange = value => { + onSliderChange = (value) => { log(value); this.setState({ value }); }; - onMinChange = e => { + onMinChange = (e) => { this.setState({ min: +e.target.value || 0, }); }; - onMaxChange = e => { + onMaxChange = (e) => { this.setState({ max: +e.target.value || 100, }); }; - onStepChange = e => { + onStepChange = (e) => { this.setState({ step: +e.target.value || 1, }); @@ -203,7 +217,7 @@ export default () => (

Slider with tooltip, with custom `tipFormatter`

- (

Slider with null value and reset button

+
+

Range Slider with null value and reset button

+ +

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

diff --git a/docs/examples/vertical.jsx b/docs/examples/vertical.tsx similarity index 90% rename from docs/examples/vertical.jsx rename to docs/examples/vertical.tsx index 96b84d150..7c662d3f4 100644 --- a/docs/examples/vertical.jsx +++ b/docs/examples/vertical.tsx @@ -2,7 +2,13 @@ import React from 'react'; import Slider from 'rc-slider'; import '../../assets/index.less'; -const style = { float: 'left', width: 160, height: 400, marginBottom: 160, marginLeft: 50 }; +const style: React.CSSProperties = { + float: 'left', + width: 160, + height: 400, + marginBottom: 160, + marginLeft: 50, +}; const parentStyle = { overflow: 'hidden' }; const marks = { @@ -67,11 +73,12 @@ export default () => (

Range with marks

- +

Range with marks and steps

- (

Range with marks and draggableTrack

- (

Range with marks and draggableTrack(reverse)

- string; - onMouseEnter?: React.MouseEventHandler; - onMouseLeave?: React.MouseEventHandler; -} - -export default class Handle extends React.Component { - state = { - clickFocused: false, - }; - - onMouseUpListener: { remove: () => void }; - - handle: HTMLElement; - - componentDidMount() { - // mouseup won't trigger if mouse moved out of handle, - // so we listen on document here. - this.onMouseUpListener = addEventListener(document, 'mouseup', this.handleMouseUp); - } - - componentWillUnmount() { - if (this.onMouseUpListener) { - this.onMouseUpListener.remove(); - } - } - - setHandleRef = node => { - this.handle = node; - }; - - setClickFocus(focused) { - this.setState({ clickFocused: focused }); - } - - handleMouseUp = () => { - if (document.activeElement === this.handle) { - this.setClickFocus(true); - } - }; - - handleMouseDown = (e) => { - // avoid selecting text during drag - // https://github.com/ant-design/ant-design/issues/25010 - e.preventDefault(); - // fix https://github.com/ant-design/ant-design/issues/15324 - this.focus(); - }; - - handleBlur = () => { - this.setClickFocus(false); - }; - - handleKeyDown = () => { - this.setClickFocus(false); - }; - - clickFocus() { - this.setClickFocus(true); - this.focus(); - } - - focus() { - this.handle.focus(); - } - - blur() { - this.handle.blur(); - } - - render() { - const { - prefixCls, - vertical, - reverse, - offset, - style, - disabled, - min, - max, - value, - tabIndex, - ariaLabel, - ariaLabelledBy, - ariaValueTextFormatter, - ...restProps - } = this.props; - - const className = classNames(this.props.className, { - [`${prefixCls}-handle-click-focused`]: this.state.clickFocused, - }); - const positionStyle = vertical - ? { - [reverse ? 'top' : 'bottom']: `${offset}%`, - [reverse ? 'bottom' : 'top']: 'auto', - transform: reverse ? null : `translateY(+50%)`, - } - : { - [reverse ? 'right' : 'left']: `${offset}%`, - [reverse ? 'left' : 'right']: 'auto', - transform: `translateX(${reverse ? '+' : '-'}50%)`, - }; - const elStyle = { - ...style, - ...positionStyle, - }; - - let mergedTabIndex = tabIndex || 0; - if (disabled || tabIndex === null) { - mergedTabIndex = null; - } - - let ariaValueText; - if (ariaValueTextFormatter) { - ariaValueText = ariaValueTextFormatter(value); - } - - return ( -
- ); - } -} diff --git a/src/Handles/Handle.tsx b/src/Handles/Handle.tsx new file mode 100644 index 000000000..0d5e25348 --- /dev/null +++ b/src/Handles/Handle.tsx @@ -0,0 +1,155 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import KeyCode from 'rc-util/lib/KeyCode'; +import SliderContext from '../context'; +import { getDirectionStyle, getIndex } from '../util'; +import type { OnStartMove } from '../interface'; + +interface RenderProps { + prefixCls: string; + value: number; + dragging: boolean; +} + +export interface HandleProps { + prefixCls: string; + style?: React.CSSProperties; + value: number; + valueIndex: number; + dragging: boolean; + onStartMove: OnStartMove; + onOffsetChange: (value: number | 'min' | 'max', valueIndex: number) => void; + onFocus?: (e: React.FocusEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + render?: (origin: React.ReactElement, props: RenderProps) => React.ReactElement; +} + +const Handle = React.forwardRef((props: HandleProps, ref: React.Ref) => { + const { + prefixCls, + value, + valueIndex, + onStartMove, + style, + render, + dragging, + onOffsetChange, + ...restProps + } = props; + const { + min, + max, + direction, + disabled, + range, + tabIndex, + ariaLabelForHandle, + ariaLabelledByForHandle, + ariaValueTextFormatterForHandle, + } = React.useContext(SliderContext); + const handlePrefixCls = `${prefixCls}-handle`; + + // ============================ Events ============================ + const onInternalStartMove = (e: React.MouseEvent | React.TouchEvent) => { + if (!disabled) { + onStartMove(e, valueIndex); + } + }; + + // =========================== Keyboard =========================== + const onKeyDown: React.KeyboardEventHandler = (e) => { + if (!disabled) { + let offset: number | 'min' | 'max' = null; + + // Change the value + switch (e.which || e.keyCode) { + case KeyCode.LEFT: + offset = direction === 'ltr' || direction === 'btt' ? -1 : 1; + break; + + case KeyCode.RIGHT: + offset = direction === 'ltr' || direction === 'btt' ? 1 : -1; + break; + + // Up is plus + case KeyCode.UP: + offset = direction !== 'ttb' ? 1 : -1; + break; + + // Down is minus + case KeyCode.DOWN: + offset = direction !== 'ttb' ? -1 : 1; + break; + + case KeyCode.HOME: + offset = 'min'; + break; + + case KeyCode.END: + offset = 'max'; + break; + + case KeyCode.PAGE_UP: + offset = 2; + break; + + case KeyCode.PAGE_DOWN: + offset = -2; + break; + } + + if (offset !== null) { + e.preventDefault(); + onOffsetChange(offset, valueIndex); + } + } + }; + + // ============================ Offset ============================ + const positionStyle = getDirectionStyle(direction, value, min, max); + + // ============================ Render ============================ + let handleNode = ( +
+ ); + + // Customize + if (render) { + handleNode = render(handleNode, { + prefixCls, + value, + dragging, + }); + } + + return handleNode; +}); + +if (process.env.NODE_ENV !== 'production') { + Handle.displayName = 'Handle'; +} + +export default Handle; diff --git a/src/Handles/index.tsx b/src/Handles/index.tsx new file mode 100644 index 000000000..4af9c70cf --- /dev/null +++ b/src/Handles/index.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import Handle from './Handle'; +import type { HandleProps } from './Handle'; +import { getIndex } from '../util'; +import type { OnStartMove } from '../interface'; + +export interface HandlesProps { + prefixCls: string; + style?: React.CSSProperties | React.CSSProperties[]; + values: number[]; + onStartMove: OnStartMove; + onOffsetChange: (value: number | 'min' | 'max', valueIndex: number) => void; + onFocus?: (e: React.FocusEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + handleRender?: HandleProps['render']; + draggingIndex: number; +} + +export interface HandlesRef { + focus: (index: number) => void; +} + +const Handles = React.forwardRef((props: HandlesProps, ref: React.Ref) => { + const { + prefixCls, + style, + onStartMove, + onOffsetChange, + values, + handleRender, + draggingIndex, + ...restProps + } = props; + const handlesRef = React.useRef>({}); + + React.useImperativeHandle(ref, () => ({ + focus: (index: number) => { + handlesRef.current[index]?.focus(); + }, + })); + + return ( + <> + {values.map((value, index) => ( + { + if (!node) { + delete handlesRef.current[index]; + } else { + handlesRef.current[index] = node; + } + }} + dragging={draggingIndex === index} + prefixCls={prefixCls} + style={getIndex(style, index)} + key={index} + value={value} + valueIndex={index} + onStartMove={onStartMove} + onOffsetChange={onOffsetChange} + render={handleRender} + {...restProps} + /> + ))} + + ); +}); + +if (process.env.NODE_ENV !== 'production') { + Handles.displayName = 'Handles'; +} + +export default Handles; diff --git a/src/Marks/Mark.tsx b/src/Marks/Mark.tsx new file mode 100644 index 000000000..91d0300f6 --- /dev/null +++ b/src/Marks/Mark.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { getDirectionStyle } from '../util'; +import SliderContext from '../context'; + +export interface MarkProps { + prefixCls: string; + children?: React.ReactNode; + style?: React.CSSProperties; + value: number; + onClick: (value: number) => void; +} + +export default function Mark(props: MarkProps) { + const { prefixCls, style, children, value, onClick } = props; + const { min, max, direction, includedStart, includedEnd, included } = + React.useContext(SliderContext); + + const textCls = `${prefixCls}-text`; + + // ============================ Offset ============================ + const positionStyle = getDirectionStyle(direction, value, min, max); + + if (!children && typeof children !== 'number') { + return null; + } + + return ( + { + e.stopPropagation(); + }} + onClick={() => { + onClick(value); + }} + > + {children} + + ); +} diff --git a/src/Marks/index.tsx b/src/Marks/index.tsx new file mode 100644 index 000000000..1fd1bab94 --- /dev/null +++ b/src/Marks/index.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import Mark from './Mark'; + +export interface MarkObj { + style?: React.CSSProperties; + label?: React.ReactNode; +} + +export interface InternalMarkObj extends MarkObj { + value: number; +} + +export interface MarksProps { + prefixCls: string; + marks?: InternalMarkObj[]; + onClick: (value: number) => void; +} + +export default function Marks(props: MarksProps) { + const { prefixCls, marks, onClick } = props; + + const markPrefixCls = `${prefixCls}-mark`; + + // Not render mark if empty + if (!marks.length) { + return null; + } + + return ( +
+ {marks.map(({ value, style, label }) => ( + + {label} + + ))} +
+ ); +} diff --git a/src/Range.tsx b/src/Range.tsx deleted file mode 100644 index e4504dfcc..000000000 --- a/src/Range.tsx +++ /dev/null @@ -1,565 +0,0 @@ -import React from 'react'; -import classNames from 'classnames'; -import Track from './common/Track'; -import createSlider from './common/createSlider'; -import * as utils from './utils'; -import type { SliderProps } from './Slider'; -import type { GenericSliderProps, GenericSliderState } from './interface'; - -const trimAlignValue = ({ - value, - handle, - bounds, - props, -}: { - value: number; - handle: number; - bounds?: number[]; - props: RangeProps; -}) => { - const { allowCross, pushable } = props; - const thershold = Number(pushable); - const valInRange = utils.ensureValueInRange(value, props); - let valNotConflict = valInRange; - if (!allowCross && handle != null && bounds !== undefined) { - if (handle > 0 && valInRange <= bounds[handle - 1] + thershold) { - valNotConflict = bounds[handle - 1] + thershold; - } - if (handle < bounds.length - 1 && valInRange >= bounds[handle + 1] - thershold) { - valNotConflict = bounds[handle + 1] - thershold; - } - } - return utils.ensureValuePrecision(valNotConflict, props); -}; - -export interface RangeProps extends GenericSliderProps { - value?: number[]; - defaultValue?: number[]; - count?: number; - min?: number; - max?: number; - allowCross?: boolean; - pushable?: boolean | number; - onChange?: (value: number[]) => void; - onBeforeChange?: (value: number[]) => void; - onAfterChange?: (value: number[]) => void; - reverse?: boolean; - vertical?: boolean; - marks?: Record; - step?: number | null; - threshold?: number; - prefixCls?: string; - included?: boolean; - disabled?: boolean; - trackStyle?: React.CSSProperties[]; - handleStyle?: React.CSSProperties[]; - tabIndex?: number | number[]; - ariaLabelGroupForHandles?: string | string[]; - ariaLabelledByGroupForHandles?: string | string[]; - ariaValueTextFormatterGroupForHandles?: ((value: number) => string)[]; - handle?: SliderProps['handle']; - draggableTrack?: boolean; -} - -interface RangeState extends GenericSliderState { - bounds: number[]; - handle: number | null; - recent: number; -} - -class Range extends React.Component { - /** - * [Legacy] Used for inherit other component. - * It's a bad code style which should be refactor. - */ - /* eslint-disable @typescript-eslint/no-unused-vars, class-methods-use-this */ - calcValueByPos(value: number) { - return 0; - } - - getSliderLength() { - return 0; - } - - calcOffset(value: number) { - return 0; - } - - saveHandle(index: number, h: any) {} - - removeDocumentEvents() {} - /* eslint-enable */ - - static displayName = 'Range'; - - static defaultProps = { - count: 1, - allowCross: true, - pushable: false, - draggableTrack: false, - tabIndex: [], - ariaLabelGroupForHandles: [], - ariaLabelledByGroupForHandles: [], - ariaValueTextFormatterGroupForHandles: [], - }; - - startValue: number; - - startPosition: number; - - prevMovedHandleIndex: number; - - internalPointsCache: { marks: RangeProps['marks']; step: number; points: number[] }; - - handlesRefs: Record; - - dragTrack: boolean; - - constructor(props: RangeProps) { - super(props); - - const { count, min, max } = props; - const initialValue = Array(...Array(count + 1)).map(() => min); - const defaultValue = 'defaultValue' in props ? props.defaultValue : initialValue; - const value = props.value !== undefined ? props.value : defaultValue; - const bounds = value.map((v, i) => - trimAlignValue({ - value: v, - handle: i, - props, - }), - ); - const recent = bounds[0] === max ? 0 : bounds.length - 1; - - this.state = { - handle: null, - recent, - bounds, - }; - } - - static getDerivedStateFromProps(props, state) { - if (!('value' in props || 'min' in props || 'max' in props)) { - return null; - } - - const value = props.value || state.bounds; - let nextBounds = value.map((v, i) => - trimAlignValue({ - value: v, - handle: i, - bounds: state.bounds, - props, - }), - ); - - if (state.bounds.length === nextBounds.length) { - if (nextBounds.every((v, i) => v === state.bounds[i])) { - return null; - } - } else { - nextBounds = value.map((v, i) => - trimAlignValue({ - value: v, - handle: i, - props, - }), - ); - } - - return { - ...state, - bounds: nextBounds, - }; - } - - componentDidUpdate(prevProps, prevState) { - const { onChange, value, min, max } = this.props; - if (!('min' in this.props || 'max' in this.props)) { - return; - } - if (min === prevProps.min && max === prevProps.max) { - return; - } - const currentValue = value || prevState.bounds; - if (currentValue.some((v) => utils.isValueOutOfRange(v, this.props))) { - const newValues = currentValue.map((v) => utils.ensureValueInRange(v, this.props)); - onChange(newValues); - } - } - - onChange(state) { - const { props } = this; - const isNotControlled = !('value' in props); - if (isNotControlled) { - this.setState(state); - } else { - const controlledState = {}; - - ['handle', 'recent'].forEach((item) => { - if (state[item] !== undefined) { - controlledState[item] = state[item]; - } - }); - - if (Object.keys(controlledState).length) { - this.setState(controlledState); - } - } - - const data = { ...this.state, ...state }; - const changedValue = data.bounds; - props.onChange(changedValue); - } - - positionGetValue = (position): number[] => { - const bounds = this.getValue(); - const value = this.calcValueByPos(position); - const closestBound = this.getClosestBound(value); - const index = this.getBoundNeedMoving(value, closestBound); - const prevValue = bounds[index]; - if (value === prevValue) return null; - - const nextBounds = [...bounds]; - nextBounds[index] = value; - return nextBounds; - }; - - onStart(position) { - const { props, state } = this; - const bounds = this.getValue(); - props.onBeforeChange(bounds); - - const value = this.calcValueByPos(position); - this.startValue = value; - this.startPosition = position; - - const closestBound = this.getClosestBound(value); - this.prevMovedHandleIndex = this.getBoundNeedMoving(value, closestBound); - - this.setState({ - handle: this.prevMovedHandleIndex, - recent: this.prevMovedHandleIndex, - }); - - const prevValue = bounds[this.prevMovedHandleIndex]; - if (value === prevValue) return; - - const nextBounds = [...state.bounds]; - nextBounds[this.prevMovedHandleIndex] = value; - this.onChange({ bounds: nextBounds }); - } - - onEnd = (force?: boolean) => { - const { handle } = this.state; - this.removeDocumentEvents(); - - if (!handle) { - this.dragTrack = false; - } - if (handle !== null || force) { - this.props.onAfterChange(this.getValue()); - } - - this.setState({ - handle: null, - }); - }; - - onMove(e, position, dragTrack, startBounds) { - utils.pauseEvent(e); - const { state, props } = this; - const maxValue = props.max || 100; - const minValue = props.min || 0; - if (dragTrack) { - let pos = props.vertical ? -position : position; - pos = props.reverse ? -pos : pos; - const max = maxValue - Math.max(...startBounds); - const min = minValue - Math.min(...startBounds); - const ratio = Math.min(Math.max(pos / (this.getSliderLength() / (maxValue - minValue)), min), max); - const nextBounds = startBounds.map((v) => - Math.floor(Math.max(Math.min(v + ratio, maxValue), minValue)), - ); - if (state.bounds.map((c, i) => c === nextBounds[i]).some((c) => !c)) { - this.onChange({ - bounds: nextBounds, - }); - } - return; - } - const value = this.calcValueByPos(position); - const oldValue = state.bounds[state.handle]; - if (value === oldValue) return; - - this.moveTo(value); - } - - onKeyboard(e) { - const { reverse, vertical } = this.props; - const valueMutator = utils.getKeyboardValueMutator(e, vertical, reverse); - - if (valueMutator) { - utils.pauseEvent(e); - const { state, props } = this; - const { bounds, handle } = state; - const oldValue = bounds[handle === null ? state.recent : handle]; - const mutatedValue = valueMutator(oldValue, props); - const value = trimAlignValue({ - value: mutatedValue, - handle, - bounds: state.bounds, - props, - }); - if (value === oldValue) return; - const isFromKeyboardEvent = true; - this.moveTo(value, isFromKeyboardEvent); - } - } - - getValue() { - return this.state.bounds; - } - - getClosestBound(value) { - const { bounds } = this.state; - let closestBound = 0; - for (let i = 1; i < bounds.length - 1; i += 1) { - if (value >= bounds[i]) { - closestBound = i; - } - } - if (Math.abs(bounds[closestBound + 1] - value) < Math.abs(bounds[closestBound] - value)) { - closestBound += 1; - } - return closestBound; - } - - getBoundNeedMoving(value, closestBound) { - const { bounds, recent } = this.state; - let boundNeedMoving = closestBound; - const isAtTheSamePoint = bounds[closestBound + 1] === bounds[closestBound]; - - if (isAtTheSamePoint && bounds[recent] === bounds[closestBound]) { - boundNeedMoving = recent; - } - - if (isAtTheSamePoint && value !== bounds[closestBound + 1]) { - boundNeedMoving = value < bounds[closestBound + 1] ? closestBound : closestBound + 1; - } - return boundNeedMoving; - } - - getLowerBound() { - return this.state.bounds[0]; - } - - getUpperBound() { - const { bounds } = this.state; - return bounds[bounds.length - 1]; - } - - /** - * Returns an array of possible slider points, taking into account both - * `marks` and `step`. The result is cached. - */ - getPoints() { - const { marks, step, min, max } = this.props; - const cache = this.internalPointsCache; - if (!cache || cache.marks !== marks || cache.step !== step) { - const pointsObject = { ...marks }; - if (step !== null) { - for (let point = min; point <= max; point += step) { - pointsObject[point] = point; - } - } - const points = Object.keys(pointsObject).map(parseFloat); - points.sort((a, b) => a - b); - this.internalPointsCache = { marks, step, points }; - } - return this.internalPointsCache.points; - } - - moveTo(value: number, isFromKeyboardEvent?: boolean) { - const { state, props } = this; - const nextBounds = [...state.bounds]; - const handle = state.handle === null ? state.recent : state.handle; - nextBounds[handle] = value; - let nextHandle = handle; - if (props.pushable !== false) { - this.pushSurroundingHandles(nextBounds, nextHandle); - } else if (props.allowCross) { - nextBounds.sort((a, b) => a - b); - nextHandle = nextBounds.indexOf(value); - } - this.onChange({ - recent: nextHandle, - handle: nextHandle, - bounds: nextBounds, - }); - if (isFromKeyboardEvent) { - // known problem: because setState is async, - // so trigger focus will invoke handler's onEnd and another handler's onStart too early, - // cause onBeforeChange and onAfterChange receive wrong value. - // here use setState callback to hack,but not elegant - this.props.onAfterChange(nextBounds); - this.setState({}, () => { - this.handlesRefs[nextHandle].focus(); - }); - this.onEnd(); - } - } - - pushSurroundingHandles(bounds, handle) { - const value = bounds[handle]; - const { pushable } = this.props; - const threshold = Number(pushable); - - let direction = 0; - if (bounds[handle + 1] - value < threshold) { - direction = +1; // push to right - } - if (value - bounds[handle - 1] < threshold) { - direction = -1; // push to left - } - - if (direction === 0) { - return; - } - - const nextHandle = handle + direction; - const diffToNext = direction * (bounds[nextHandle] - value); - if (!this.pushHandle(bounds, nextHandle, direction, threshold - diffToNext)) { - // revert to original value if pushing is impossible - // eslint-disable-next-line no-param-reassign - bounds[handle] = bounds[nextHandle] - direction * threshold; - } - } - - pushHandle(bounds: number[], handle: number, direction: number, amount: number) { - const originalValue = bounds[handle]; - let currentValue = bounds[handle]; - while (direction * (currentValue - originalValue) < amount) { - if (!this.pushHandleOnePoint(bounds, handle, direction)) { - // can't push handle enough to create the needed `amount` gap, so we - // revert its position to the original value - // eslint-disable-next-line no-param-reassign - bounds[handle] = originalValue; - return false; - } - currentValue = bounds[handle]; - } - // the handle was pushed enough to create the needed `amount` gap - return true; - } - - pushHandleOnePoint(bounds, handle, direction) { - const points = this.getPoints(); - const pointIndex = points.indexOf(bounds[handle]); - const nextPointIndex = pointIndex + direction; - if (nextPointIndex >= points.length || nextPointIndex < 0) { - // reached the minimum or maximum available point, can't push anymore - return false; - } - const nextHandle = handle + direction; - const nextValue = points[nextPointIndex]; - const { pushable } = this.props; - const threshold = Number(pushable); - const diffToNext = direction * (bounds[nextHandle] - nextValue); - if (!this.pushHandle(bounds, nextHandle, direction, threshold - diffToNext)) { - // couldn't push next handle, so we won't push this one either - return false; - } - // push the handle - // eslint-disable-next-line no-param-reassign - bounds[handle] = nextValue; - return true; - } - - trimAlignValue(value) { - const { handle, bounds } = this.state; - return trimAlignValue({ - value, - handle, - bounds, - props: this.props, - }); - } - - render() { - const { handle, bounds } = this.state; - const { - prefixCls, - vertical, - included, - disabled, - min, - max, - reverse, - handle: handleGenerator, - trackStyle, - handleStyle, - tabIndex, - ariaLabelGroupForHandles, - ariaLabelledByGroupForHandles, - ariaValueTextFormatterGroupForHandles, - } = this.props; - - const offsets = bounds.map((v) => this.calcOffset(v)); - - const handleClassName = `${prefixCls}-handle`; - const handles = bounds.map((v, i) => { - let mergedTabIndex = tabIndex[i] || 0; - if (disabled || tabIndex[i] === null) { - mergedTabIndex = null; - } - const dragging = handle === i; - return handleGenerator({ - className: classNames({ - [handleClassName]: true, - [`${handleClassName}-${i + 1}`]: true, - [`${handleClassName}-dragging`]: dragging, - }), - prefixCls, - vertical, - dragging, - offset: offsets[i], - value: v, - index: i, - tabIndex: mergedTabIndex, - min, - max, - reverse, - disabled, - style: handleStyle[i], - ref: (h) => this.saveHandle(i, h), - ariaLabel: ariaLabelGroupForHandles[i], - ariaLabelledBy: ariaLabelledByGroupForHandles[i], - ariaValueTextFormatter: ariaValueTextFormatterGroupForHandles[i], - }); - }); - - const tracks = bounds.slice(0, -1).map((_, index) => { - const i = index + 1; - const trackClassName = classNames({ - [`${prefixCls}-track`]: true, - [`${prefixCls}-track-${i}`]: true, - }); - return ( - - ); - }); - - return { tracks, handles }; - } -} - -export default createSlider(Range); diff --git a/src/Slider.tsx b/src/Slider.tsx index 77ad0711a..fb38d8fa9 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -1,277 +1,527 @@ -import React from 'react'; +import * as React from 'react'; +import classNames from 'classnames'; +import shallowEqual from 'shallowequal'; +import useMergedState from 'rc-util/lib/hooks/useMergedState'; +import type { HandlesRef } from './Handles'; +import Handles from './Handles'; +import type { HandlesProps } from './Handles'; +import useDrag from './hooks/useDrag'; +import SliderContext from './context'; +import type { SliderContextProps } from './context'; +import Tracks from './Tracks'; +import type { AriaValueFormat, Direction, OnStartMove } from './interface'; +import Marks from './Marks'; +import type { MarkObj } from './Marks'; +import type { InternalMarkObj } from './Marks'; +import Steps from './Steps'; +import useOffset from './hooks/useOffset'; import warning from 'rc-util/lib/warning'; -import Track from './common/Track'; -import createSlider from './common/createSlider'; -import * as utils from './utils'; -import type { GenericSliderProps, GenericSliderState } from './interface'; - -export interface SliderProps extends GenericSliderProps { - value?: number; - defaultValue?: number; + +/** + * New: + * - click mark to update range value + * - handleRender + * - Fix handle with count not correct + * - Fix pushable not work in some case + * - No more FindDOMNode + * - Move all position related style into inline style + * - Key: up is plus, down is minus + * - fix Key with step = null not align with marks + * - Change range should not trigger onChange + * - keyboard support pushable + */ + +export interface SliderProps { + prefixCls?: string; + className?: string; + style?: React.CSSProperties; + + // Status + disabled?: boolean; + autoFocus?: boolean; + onFocus?: (e: React.FocusEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + + // Value + range?: boolean; + count?: number; min?: number; max?: number; step?: number | null; - prefixCls?: string; - onChange?: (value: number) => void; - onBeforeChange?: (value: number) => void; - onAfterChange?: (value: number) => void; + value?: ValueType; + defaultValue?: ValueType; + onChange?: (value: ValueType) => void; + /** @deprecated It's always better to use `onChange` instead */ + onBeforeChange?: (value: ValueType) => void; + /** @deprecated It's always better to use `onChange` instead */ + onAfterChange?: (value: ValueType) => void; + + // Cross + allowCross?: boolean; + pushable?: boolean | number; + /** range only */ + draggableTrack?: boolean; + + // Direction + reverse?: boolean; vertical?: boolean; + + // Style included?: boolean; - disabled?: boolean; - reverse?: boolean; - minimumTrackStyle?: React.CSSProperties; - trackStyle?: React.CSSProperties; - handleStyle?: React.CSSProperties; - tabIndex?: number; - ariaLabelForHandle?: string; - ariaLabelledByForHandle?: string; - ariaValueTextFormatterForHandle?: (value: number) => string; startPoint?: number; - handle?: (props: { - className: string; - prefixCls?: string; - vertical?: boolean; - offset: number; - value: number; - dragging?: boolean; - disabled?: boolean; - min?: number; - max?: number; - reverse?: boolean; - index: number; - tabIndex?: number; - ariaLabel: string; - ariaLabelledBy: string; - ariaValueTextFormatter?: (value: number) => string; - style?: React.CSSProperties; - ref?: React.Ref; - }) => React.ReactElement; -} -export interface SliderState extends GenericSliderState { - value: number; - dragging: boolean; + trackStyle?: React.CSSProperties | React.CSSProperties[]; + handleStyle?: React.CSSProperties | React.CSSProperties[]; + railStyle?: React.CSSProperties; + dotStyle?: React.CSSProperties; + activeDotStyle?: React.CSSProperties; + + // Decorations + marks?: Record; + dots?: boolean; + + // Components + handleRender?: HandlesProps['handleRender']; + + // Accessibility + tabIndex?: number | number[]; + ariaLabelForHandle?: string | string[]; + ariaLabelledByForHandle?: string | string[]; + ariaValueTextFormatterForHandle?: AriaValueFormat | AriaValueFormat[]; } -class Slider extends React.Component { - /** - * [Legacy] Used for inherit other component. - * It's a bad code style which should be refactor. - */ - /* eslint-disable @typescript-eslint/no-unused-vars, class-methods-use-this */ - calcValueByPos(value: number) { - return 0; - } - - positionGetValue = (position): number[] => { - return []; - }; +export interface SliderRef { + focus: () => void; + blur: () => void; +} - calcOffset(value: number) { - return 0; - } +const Slider = React.forwardRef((props: SliderProps, ref: React.Ref) => { + const { + prefixCls = 'rc-slider', + className, + style, + + // Status + disabled = false, + autoFocus, + onFocus, + onBlur, + + // Value + min = 0, + max = 100, + step = 1, + value, + defaultValue, + range, + count, + onChange, + onBeforeChange, + onAfterChange, + + // Cross + allowCross = true, + pushable = false, + draggableTrack, + + // Direction + reverse, + vertical, + + // Style + included = true, + startPoint, + trackStyle, + handleStyle, + railStyle, + dotStyle, + activeDotStyle, + + // Decorations + marks, + dots, + + // Components + handleRender, + + // Accessibility + tabIndex = 0, + ariaLabelForHandle, + ariaLabelledByForHandle, + ariaValueTextFormatterForHandle, + } = props; + + const handlesRef = React.useRef(); + const containerRef = React.useRef(); + + const direction: Direction = React.useMemo(() => { + if (vertical) { + return reverse ? 'ttb' : 'btt'; + } + return reverse ? 'rtl' : 'ltr'; + }, [reverse, vertical]); - saveHandle(index: number, h: any) {} + // ============================ Range ============================= + const mergedMin = React.useMemo(() => (isFinite(min) ? min : 0), [min]); + const mergedMax = React.useMemo(() => (isFinite(max) ? max : 100), [max]); - removeDocumentEvents() {} - /* eslint-enable */ + // ============================= Step ============================= + const mergedStep = React.useMemo(() => (step !== null && step <= 0 ? 1 : step), [step]); - constructor(props: SliderProps) { - super(props); + // ============================= Push ============================= + const mergedPush = React.useMemo(() => { + if (pushable === true) { + return mergedStep; + } - const defaultValue = props.defaultValue !== undefined ? props.defaultValue : props.min; - const value = props.value !== undefined ? props.value : defaultValue; + return pushable >= 0 ? pushable : false; + }, [pushable, mergedStep]); + + // ============================ Marks ============================= + const markList = React.useMemo(() => { + const keys = Object.keys(marks || {}); + + return keys + .map((key) => { + const mark = marks[key]; + const markObj: InternalMarkObj = { + value: Number(key), + }; + + if ( + mark && + typeof mark === 'object' && + !React.isValidElement(mark) && + ('label' in mark || 'style' in mark) + ) { + markObj.style = mark.style; + markObj.label = mark.label; + } else { + markObj.label = mark; + } + + return markObj; + }) + .sort((a, b) => a.value - b.value); + }, [marks]); + + // ============================ Format ============================ + const [formatValue, offsetValues] = useOffset( + mergedMin, + mergedMax, + mergedStep, + markList, + allowCross, + mergedPush, + ); + + // ============================ Values ============================ + const [mergedValue, setValue] = useMergedState(defaultValue, { + value, + }); + + const rawValues = React.useMemo(() => { + const valueList = + mergedValue === null || mergedValue === undefined + ? [] + : Array.isArray(mergedValue) + ? mergedValue + : [mergedValue]; + + const [val0 = mergedMin] = valueList; + let returnValues = mergedValue === null ? [] : [val0]; + + // Format as range + if (range) { + returnValues = [...valueList]; + + // When count provided or value is `undefined`, we fill values + if (count || mergedValue === undefined) { + const pointCount = count >= 0 ? count + 1 : 2; + returnValues = returnValues.slice(0, pointCount); + + // Fill with count + while (returnValues.length < pointCount) { + returnValues.push(returnValues[returnValues.length - 1] ?? mergedMin); + } + } + returnValues.sort((a, b) => a - b); + } - this.state = { - value: this.trimAlignValue(value), - dragging: false, - }; + // Align in range + returnValues.forEach((val, index) => { + returnValues[index] = formatValue(val); + }); - warning( - !('minimumTrackStyle' in props), - 'minimumTrackStyle will be deprecated, please use trackStyle instead.', - ); - warning( - !('maximumTrackStyle' in props), - 'maximumTrackStyle will be deprecated, please use railStyle instead.', - ); - } + return returnValues; + }, [mergedValue, range, mergedMin, count, formatValue]); - startValue: number; + // =========================== onChange =========================== + const rawValuesRef = React.useRef(rawValues); + rawValuesRef.current = rawValues; - startPosition: number; + const getTriggerValue = (triggerValues: number[]) => (range ? triggerValues : triggerValues[0]); - prevMovedHandleIndex: number; + const triggerChange = (nextValues: number[]) => { + // Order first + const cloneNextValues = [...nextValues].sort((a, b) => a - b); - componentDidUpdate(prevProps: SliderProps, prevState: SliderState) { - const { min, max, value, onChange } = this.props; - if (!('min' in this.props || 'max' in this.props)) { - return; - } - const theValue = value !== undefined ? value : prevState.value; - const nextValue = this.trimAlignValue(theValue, this.props); - if (nextValue === prevState.value) { - return; - } - // eslint-disable-next-line - this.setState({ value: nextValue }); - if ( - !(min === prevProps.min && max === prevProps.max) && - utils.isValueOutOfRange(theValue, this.props) - ) { - onChange(nextValue); - } - } - - onChange(state: { value: number }) { - const { props } = this; - const isNotControlled = !('value' in props); - const nextState = state.value > this.props.max ? { ...state, value: this.props.max } : state; - if (isNotControlled) { - this.setState(nextState); + // Trigger event if needed + if (onChange && !shallowEqual(cloneNextValues, rawValuesRef.current)) { + onChange(getTriggerValue(cloneNextValues)); } - const changedValue = nextState.value; - props.onChange(changedValue); - } + // We set this later since it will re-render component immediately + setValue(cloneNextValues); + }; - onStart(position: number) { - this.setState({ dragging: true }); - const { props } = this; - const prevValue = this.getValue(); - props.onBeforeChange(prevValue); + const changeToCloseValue = (newValue: number) => { + if (!disabled) { + let valueIndex = 0; + let valueDist = mergedMax - mergedMin; - const value = this.calcValueByPos(position); - this.startValue = value; - this.startPosition = position; + rawValues.forEach((val, index) => { + const dist = Math.abs(newValue - val); + if (dist <= valueDist) { + valueDist = dist; + valueIndex = index; + } + }); - if (value === prevValue) return; + // Create new values + const cloneNextValues = [...rawValues]; - this.prevMovedHandleIndex = 0; + cloneNextValues[valueIndex] = newValue; - this.onChange({ value }); - } + // Fill value to match default 2 + if (range && !rawValues.length && count === undefined) { + cloneNextValues.push(newValue); + } - onEnd = (force?: boolean) => { - const { dragging } = this.state; - this.removeDocumentEvents(); - if (dragging || force) { - this.props.onAfterChange(this.getValue()); + triggerChange(cloneNextValues); + onAfterChange?.(cloneNextValues); } - this.setState({ dragging: false }); }; - onMove(e, position) { - utils.pauseEvent(e); - const { value: oldValue } = this.state; - const value = this.calcValueByPos(position); - if (value === oldValue) return; - - this.onChange({ value }); - } - - onKeyboard(e) { - const { reverse, vertical } = this.props; - const valueMutator = utils.getKeyboardValueMutator(e, vertical, reverse); - if (valueMutator) { - utils.pauseEvent(e); - const { state } = this; - const oldValue = state.value; - const mutatedValue = valueMutator(oldValue, this.props); - const value = this.trimAlignValue(mutatedValue); - if (value === oldValue) return; - - this.onChange({ value }); - this.props.onAfterChange(value); - this.onEnd(); + // ============================ Click ============================= + const onSliderMouseDown: React.MouseEventHandler = (e) => { + e.preventDefault(); + + const { width, height, left, top, bottom, right } = + containerRef.current.getBoundingClientRect(); + const { clientX, clientY } = e; + + let percent: number; + switch (direction) { + case 'btt': + percent = (bottom - clientY) / height; + break; + + case 'ttb': + percent = (clientY - top) / height; + break; + + case 'rtl': + percent = (right - clientX) / width; + break; + + default: + percent = (clientX - left) / width; } - } - getValue() { - return this.state.value; - } + const nextValue = mergedMin + percent * (mergedMax - mergedMin); + changeToCloseValue(formatValue(nextValue)); + }; + + // =========================== Keyboard =========================== + const [keyboardValue, setKeyboardValue] = React.useState(null); + + const onHandleOffsetChange = (offset: number | 'min' | 'max', valueIndex: number) => { + if (!disabled) { + const next = offsetValues(rawValues, offset, valueIndex); + + onBeforeChange?.(getTriggerValue(rawValues)); + triggerChange(next.values); + onAfterChange?.(getTriggerValue(next.values)); - getLowerBound() { - const minPoint = this.props.startPoint || this.props.min; - return this.state.value > minPoint ? minPoint : this.state.value; - } + setKeyboardValue(next.value); + } + }; - getUpperBound() { - if (this.state.value < this.props.startPoint) { - return this.props.startPoint; + React.useEffect(() => { + if (keyboardValue !== null) { + const valueIndex = rawValues.indexOf(keyboardValue); + if (valueIndex >= 0) { + handlesRef.current.focus(valueIndex); + } } - return this.state.value; - } - trimAlignValue(v: number, nextProps: Partial = {}) { - if (v === null) { - return null; + setKeyboardValue(null); + }, [keyboardValue]); + + // ============================= Drag ============================= + const mergedDraggableTrack = React.useMemo(() => { + if (draggableTrack && mergedStep === null) { + if (process.env.NODE_ENV !== 'production') { + warning(false, '`draggableTrack` is not supported when `step` is `null`.'); + } + return false; } + return draggableTrack; + }, [draggableTrack, mergedStep]); - const mergedProps = { ...this.props, ...nextProps }; - const val = utils.ensureValueInRange(v, mergedProps); - return utils.ensureValuePrecision(val, mergedProps); - } + const finishChange = () => { + onAfterChange?.(getTriggerValue(rawValuesRef.current)); + }; - render() { - const { - prefixCls, - vertical, - included, + const [draggingIndex, draggingValue, cacheValues, onStartDrag] = useDrag( + containerRef, + direction, + rawValues, + mergedMin, + mergedMax, + formatValue, + triggerChange, + finishChange, + offsetValues, + ); + + const onStartMove: OnStartMove = (e, valueIndex) => { + onStartDrag(e, valueIndex); + + onBeforeChange?.(getTriggerValue(rawValuesRef.current)); + }; + + // Auto focus for updated handle + const dragging = draggingIndex !== -1; + React.useEffect(() => { + if (!dragging) { + const valueIndex = rawValues.lastIndexOf(draggingValue); + handlesRef.current.focus(valueIndex); + } + }, [dragging]); + + // =========================== Included =========================== + const sortedCacheValues = React.useMemo( + () => [...cacheValues].sort((a, b) => a - b), + [cacheValues], + ); + + // Provide a range values with included [min, max] + // Used for Track, Mark & Dot + const [includedStart, includedEnd] = React.useMemo(() => { + if (!range) { + return [mergedMin, sortedCacheValues[0]]; + } + + return [sortedCacheValues[0], sortedCacheValues[sortedCacheValues.length - 1]]; + }, [sortedCacheValues, range, mergedMin]); + + // ============================= Refs ============================= + React.useImperativeHandle(ref, () => ({ + focus: () => { + handlesRef.current.focus(0); + }, + blur: () => { + const { activeElement } = document; + if (containerRef.current.contains(activeElement)) { + (activeElement as HTMLElement)?.blur(); + } + }, + })); + + // ========================== Auto Focus ========================== + React.useEffect(() => { + if (autoFocus) { + handlesRef.current.focus(0); + } + }, []); + + // =========================== Context ============================ + const context = React.useMemo( + () => ({ + min: mergedMin, + max: mergedMax, + direction, disabled, - minimumTrackStyle, - trackStyle, - handleStyle, + step: mergedStep, + included, + includedStart, + includedEnd, + range, tabIndex, ariaLabelForHandle, ariaLabelledByForHandle, ariaValueTextFormatterForHandle, - min, - max, - startPoint, - reverse, - handle: handleGenerator, - } = this.props; - const { value, dragging } = this.state; - const offset = this.calcOffset(value); - const handle = handleGenerator({ - className: `${prefixCls}-handle`, - prefixCls, - vertical, - offset, - value, - dragging, + }), + [ + mergedMin, + mergedMax, + direction, disabled, - min, - max, - reverse, - index: 0, + mergedStep, + included, + includedStart, + includedEnd, + range, tabIndex, - ariaLabel: ariaLabelForHandle, - ariaLabelledBy: ariaLabelledByForHandle, - ariaValueTextFormatter: ariaValueTextFormatterForHandle, - style: handleStyle[0] || handleStyle, - ref: (h) => this.saveHandle(0, h), - }); - - const trackOffset = startPoint !== undefined ? this.calcOffset(startPoint) : 0; - const mergedTrackStyle = trackStyle[0] || trackStyle; - const track = ( - - ); - - return { tracks: track, handles: handle }; - } + ariaLabelForHandle, + ariaLabelledByForHandle, + ariaValueTextFormatterForHandle, + ], + ); + + // ============================ Render ============================ + return ( + +
+
+ + + + + + + + +
+ + ); +}); + +if (process.env.NODE_ENV !== 'production') { + Slider.displayName = 'Slider'; } -export default createSlider(Slider); +export default Slider; diff --git a/src/Steps/Dot.tsx b/src/Steps/Dot.tsx new file mode 100644 index 000000000..85462aa8d --- /dev/null +++ b/src/Steps/Dot.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { getDirectionStyle } from '../util'; +import SliderContext from '../context'; + +export interface DotProps { + prefixCls: string; + value: number; + style?: React.CSSProperties; + activeStyle?: React.CSSProperties; +} + +export default function Dot(props: DotProps) { + const { prefixCls, value, style, activeStyle } = props; + const { min, max, direction, included, includedStart, includedEnd } = + React.useContext(SliderContext); + + const dotClassName = `${prefixCls}-dot`; + const active = included && includedStart <= value && value <= includedEnd; + + // ============================ Offset ============================ + let mergedStyle = { + ...getDirectionStyle(direction, value, min, max), + ...style, + }; + + if (active) { + mergedStyle = { + ...mergedStyle, + ...activeStyle, + }; + } + + return ( + + ); +} diff --git a/src/Steps/index.tsx b/src/Steps/index.tsx new file mode 100644 index 000000000..71c08f3aa --- /dev/null +++ b/src/Steps/index.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import type { InternalMarkObj } from '../Marks'; +import SliderContext from '../context'; +import Dot from './Dot'; + +export interface StepsProps { + prefixCls: string; + marks: InternalMarkObj[]; + dots?: boolean; + style?: React.CSSProperties; + activeStyle?: React.CSSProperties; +} + +export default function Steps(props: StepsProps) { + const { prefixCls, marks, dots, style, activeStyle } = props; + const { min, max, step } = React.useContext(SliderContext); + + const stepDots = React.useMemo(() => { + const dotSet = new Set(); + + // Add marks + marks.forEach((mark) => { + dotSet.add(mark.value); + }); + + // Fill dots + if (dots) { + let current = min; + while (current <= max) { + dotSet.add(current); + current += step; + } + } + + return Array.from(dotSet); + }, [min, max, step, dots, marks]); + + return ( +
+ {stepDots.map((dotValue) => ( + + ))} +
+ ); +} diff --git a/src/Tracks/Track.tsx b/src/Tracks/Track.tsx new file mode 100644 index 000000000..b6886eaad --- /dev/null +++ b/src/Tracks/Track.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import SliderContext from '../context'; +import { getOffset } from '../util'; +import type { OnStartMove } from '../interface'; + +export interface TrackProps { + prefixCls: string; + style?: React.CSSProperties; + start: number; + end: number; + index: number; + onStartMove?: OnStartMove; +} + +export default function Track(props: TrackProps) { + const { prefixCls, style, start, end, index, onStartMove } = props; + const { direction, min, max, disabled, range } = React.useContext(SliderContext); + + const trackPrefixCls = `${prefixCls}-track`; + + const offsetStart = getOffset(start, min, max); + const offsetEnd = getOffset(end, min, max); + + // ============================ Events ============================ + const onInternalStartMove = (e: React.MouseEvent | React.TouchEvent) => { + if (!disabled && onStartMove) { + onStartMove(e, -1); + } + }; + + // ============================ Render ============================ + const positionStyle: React.CSSProperties = {}; + + switch (direction) { + case 'rtl': + positionStyle.right = `${offsetStart * 100}%`; + positionStyle.width = `${offsetEnd * 100 - offsetStart * 100}%`; + break; + + case 'btt': + positionStyle.bottom = `${offsetStart * 100}%`; + positionStyle.height = `${offsetEnd * 100 - offsetStart * 100}%`; + break; + + case 'ttb': + positionStyle.top = `${offsetStart * 100}%`; + positionStyle.height = `${offsetEnd * 100 - offsetStart * 100}%`; + break; + + default: + positionStyle.left = `${offsetStart * 100}%`; + positionStyle.width = `${offsetEnd * 100 - offsetStart * 100}%`; + } + + return ( +
+ ); +} diff --git a/src/Tracks/index.tsx b/src/Tracks/index.tsx new file mode 100644 index 000000000..10ac4a8a4 --- /dev/null +++ b/src/Tracks/index.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import SliderContext from '../context'; +import Track from './Track'; +import type { OnStartMove } from '../interface'; +import { getIndex } from '../util'; + +export interface TrackProps { + prefixCls: string; + style?: React.CSSProperties | React.CSSProperties[]; + values: number[]; + onStartMove?: OnStartMove; + startPoint?: number; +} + +export default function Tracks(props: TrackProps) { + const { prefixCls, style, values, startPoint, onStartMove } = props; + const { included, range, min } = React.useContext(SliderContext); + + const trackList = React.useMemo(() => { + if (!range) { + // null value do not have track + if (values.length === 0) { + return []; + } + + const startValue = startPoint ?? min; + const endValue = values[0]; + + return [ + { + start: Math.min(startValue, endValue), + end: Math.max(startValue, endValue), + }, + ]; + } + + // Multiple + const list = []; + + for (let i = 0; i < values.length - 1; i += 1) { + list.push({ + start: values[i], + end: values[i + 1], + }); + } + + return list; + }, [values, range, startPoint, min]); + + return (included + ? trackList.map(({ start, end }, index) => ( + + )) + : null) as unknown as React.ReactElement; +} diff --git a/src/common/Marks.tsx b/src/common/Marks.tsx deleted file mode 100644 index 3dc42cfe6..000000000 --- a/src/common/Marks.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import classNames from 'classnames'; - -const Marks = ({ - className, - vertical, - reverse, - marks, - included, - upperBound, - lowerBound, - max, - min, - onClickLabel, -}) => { - const marksKeys = Object.keys(marks); - - const range = max - min; - const elements = marksKeys - .map(parseFloat) - .sort((a, b) => a - b) - .map(point => { - const markPoint = marks[point]; - const markPointIsObject = typeof markPoint === 'object' && !React.isValidElement(markPoint); - const markLabel = markPointIsObject ? markPoint.label : markPoint; - if (!markLabel && markLabel !== 0) { - return null; - } - - const isActive = - (!included && point === upperBound) || - (included && point <= upperBound && point >= lowerBound); - const markClassName = classNames({ - [`${className}-text`]: true, - [`${className}-text-active`]: isActive, - }); - - const bottomStyle = { - marginBottom: '-50%', - [reverse ? 'top' : 'bottom']: `${((point - min) / range) * 100}%`, - }; - - const leftStyle = { - transform: `translateX(${reverse ? `50%` : `-50%`})`, - msTransform: `translateX(${reverse ? `50%` : `-50%`})`, - [reverse ? 'right' : 'left']: `${((point - min) / range) * 100}%`, - }; - - const style = vertical ? bottomStyle : leftStyle; - const markStyle = markPointIsObject ? { ...style, ...markPoint.style } : style; - return ( - onClickLabel(e, point)} - onTouchStart={e => onClickLabel(e, point)} - > - {markLabel} - - ); - }); - - return
{elements}
; -}; - -export default Marks; diff --git a/src/common/SliderTooltip.tsx b/src/common/SliderTooltip.tsx deleted file mode 100644 index d8eda84a5..000000000 --- a/src/common/SliderTooltip.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from 'react'; -import Tooltip from 'rc-tooltip'; -import type { TooltipProps } from 'rc-tooltip/lib/Tooltip'; -import { composeRef } from 'rc-util/lib/ref'; -import raf from 'rc-util/lib/raf'; - -const SliderTooltip = React.forwardRef((props, ref) => { - const { visible, overlay } = props; - const innerRef = React.useRef(null); - const tooltipRef = composeRef(ref, innerRef); - - const rafRef = React.useRef(null); - - function cancelKeepAlign() { - raf.cancel(rafRef.current!); - } - - function keepAlign() { - rafRef.current = raf(() => { - innerRef.current?.forcePopupAlign(); - }); - } - - React.useEffect(() => { - if (visible) { - keepAlign(); - } else { - cancelKeepAlign(); - } - - return cancelKeepAlign; - }, [visible, overlay]); - - return ; -}); - -export default SliderTooltip; diff --git a/src/common/Steps.tsx b/src/common/Steps.tsx deleted file mode 100644 index cf8238707..000000000 --- a/src/common/Steps.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react'; -import classNames from 'classnames'; -import warning from 'rc-util/lib/warning'; - -const calcPoints = ( - vertical: boolean, - marks: Record, - dots: boolean, - step: number, - min: number, - max: number, -) => { - warning( - dots ? step > 0 : true, - '`Slider[step]` should be a positive number in order to make Slider[dots] work.', - ); - const points = Object.keys(marks) - .map(parseFloat) - .sort((a, b) => a - b); - if (dots && step) { - for (let i = min; i <= max; i += step) { - if (points.indexOf(i) === -1) { - points.push(i); - } - } - } - return points; -}; - -const Steps = ({ - prefixCls, - vertical, - reverse, - marks, - dots, - step, - included, - lowerBound, - upperBound, - max, - min, - dotStyle, - activeDotStyle, -}) => { - const range = max - min; - const elements = calcPoints(vertical, marks, dots, step, min, max).map(point => { - const offset = `${(Math.abs(point - min) / range) * 100}%`; - - const isActived = - (!included && point === upperBound) || - (included && point <= upperBound && point >= lowerBound); - let style = vertical - ? { ...dotStyle, [reverse ? 'top' : 'bottom']: offset } - : { ...dotStyle, [reverse ? 'right' : 'left']: offset }; - if (isActived) { - style = { ...style, ...activeDotStyle }; - } - - const pointClassName = classNames({ - [`${prefixCls}-dot`]: true, - [`${prefixCls}-dot-active`]: isActived, - [`${prefixCls}-dot-reverse`]: reverse, - }); - - return ; - }); - - return
{elements}
; -}; - -export default Steps; diff --git a/src/common/Track.tsx b/src/common/Track.tsx deleted file mode 100644 index fd662a10e..000000000 --- a/src/common/Track.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; - -const Track = props => { - const { className, included, vertical, style } = props; - let { length, offset, reverse } = props; - if (length < 0) { - reverse = !reverse; - length = Math.abs(length); - offset = 100 - offset; - } - - const positonStyle = vertical - ? { - [reverse ? 'top' : 'bottom']: `${offset}%`, - [reverse ? 'bottom' : 'top']: 'auto', - height: `${length}%`, - } - : { - [reverse ? 'right' : 'left']: `${offset}%`, - [reverse ? 'left' : 'right']: 'auto', - width: `${length}%`, - }; - - const elStyle = { - ...style, - ...positonStyle, - }; - return included ?
: null; -}; - -export default Track; diff --git a/src/common/createSlider.tsx b/src/common/createSlider.tsx deleted file mode 100644 index a90dfe768..000000000 --- a/src/common/createSlider.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import React from 'react'; -import addEventListener from 'rc-util/lib/Dom/addEventListener'; -import classNames from 'classnames'; -import warning from 'rc-util/lib/warning'; -import Steps from './Steps'; -import Marks from './Marks'; -import type { HandleProps } from '../Handle'; -import Handle from '../Handle'; -import * as utils from '../utils'; -import type { GenericSliderProps, GenericSliderState, GenericSlider } from '../interface'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -function noop() {} - -export default function createSlider< - Props extends GenericSliderProps, - State extends GenericSliderState ->(Component: GenericSlider): React.ComponentClass { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - return class ComponentEnhancer extends Component { - static displayName = `ComponentEnhancer(${Component.displayName})`; - - static defaultProps = { - ...Component.defaultProps, - prefixCls: 'rc-slider', - className: '', - min: 0, - max: 100, - step: 1, - marks: {}, - handle(props: HandleProps & { index: number; dragging: boolean }) { - const { index, ...restProps } = props; - delete restProps.dragging; - if (restProps.value === null) { - return null; - } - - return ; - }, - onBeforeChange: noop, - onChange: noop, - onAfterChange: noop, - included: true, - disabled: false, - dots: false, - vertical: false, - reverse: false, - trackStyle: [{}], - handleStyle: [{}], - railStyle: {}, - dotStyle: {}, - activeDotStyle: {}, - }; - - handlesRefs: any; - - sliderRef: HTMLDivElement; - - document: Document; - - dragOffset: number; - - prevMovedHandleIndex: number; - - onTouchMoveListener: any; - - onTouchUpListener: any; - - onMouseMoveListener: any; - - onMouseUpListener: any; - - dragTrack: boolean; - - startBounds: number[]; - - constructor(props: Props) { - super(props); - - const { step, max, min } = props; - const isPointDiffEven = isFinite(max - min) ? (max - min) % step === 0 : true; // eslint-disable-line - warning( - step && Math.floor(step) === step ? isPointDiffEven : true, - `Slider[max] - Slider[min] (${max - min}) should be a multiple of Slider[step] (${step})`, - ); - this.handlesRefs = {}; - } - - componentDidMount() { - // Snapshot testing cannot handle refs, so be sure to null-check this. - this.document = this.sliderRef && this.sliderRef.ownerDocument; - - const { autoFocus, disabled } = this.props; - if (autoFocus && !disabled) { - this.focus(); - } - } - - componentWillUnmount() { - if (super.componentWillUnmount) super.componentWillUnmount(); - this.removeDocumentEvents(); - } - - onDown = (e, position) => { - let p = position; - const { draggableTrack, vertical: isVertical } = this.props; - const { bounds } = this.state; - - const value = draggableTrack && this.positionGetValue ? this.positionGetValue(p) || [] : []; - - const inPoint = utils.isEventFromHandle(e, this.handlesRefs); - this.dragTrack = - draggableTrack && - bounds.length >= 2 && - !inPoint && - !value - .map((n, i) => { - const v = !i ? n >= bounds[i] : true; - return i === value.length - 1 ? n <= bounds[i] : v; - }) - .some((c) => !c); - - if (this.dragTrack) { - this.dragOffset = p; - this.startBounds = [...bounds]; - } else { - if (!inPoint) { - this.dragOffset = 0; - } else { - const handlePosition = utils.getHandleCenterPosition(isVertical, e.target); - this.dragOffset = p - handlePosition; - p = handlePosition; - } - this.onStart(p); - } - }; - - onMouseDown = (e: any) => { - if (e.button !== 0) { - return; - } - - this.removeDocumentEvents(); - const isVertical = this.props.vertical; - const position = utils.getMousePosition(isVertical, e); - this.onDown(e, position); - this.addDocumentMouseEvents(); - }; - - onTouchStart = (e: any) => { - if (utils.isNotTouchEvent(e)) return; - const isVertical = this.props.vertical; - const position = utils.getTouchPosition(isVertical, e); - this.onDown(e, position); - this.addDocumentTouchEvents(); - utils.pauseEvent(e); - }; - - onFocus = (e: React.FocusEvent) => { - const { onFocus, vertical } = this.props; - if (utils.isEventFromHandle(e, this.handlesRefs) && !this.dragTrack) { - const handlePosition = utils.getHandleCenterPosition(vertical, e.target); - this.dragOffset = 0; - this.onStart(handlePosition); - utils.pauseEvent(e); - if (onFocus) { - onFocus(e); - } - } - }; - - onBlur = (e: React.FocusEvent) => { - const { onBlur } = this.props; - if (!this.dragTrack) { - this.onEnd(); - } - - if (onBlur) { - onBlur(e); - } - }; - - onMouseUp = () => { - if (this.handlesRefs[this.prevMovedHandleIndex]) { - this.handlesRefs[this.prevMovedHandleIndex].clickFocus(); - } - }; - - onMouseMove = (e: React.MouseEvent) => { - if (!this.sliderRef) { - this.onEnd(); - return; - } - const position = utils.getMousePosition(this.props.vertical, e); - this.onMove(e, position - this.dragOffset, this.dragTrack, this.startBounds); - }; - - onTouchMove = (e: React.TouchEvent) => { - if (utils.isNotTouchEvent(e) || !this.sliderRef) { - this.onEnd(); - return; - } - - const position = utils.getTouchPosition(this.props.vertical, e); - this.onMove(e, position - this.dragOffset, this.dragTrack, this.startBounds); - }; - - onKeyDown = (e: React.KeyboardEvent) => { - if (this.sliderRef && utils.isEventFromHandle(e as any, this.handlesRefs)) { - this.onKeyboard(e); - } - }; - - onClickMarkLabel = (e: React.MouseEvent, value: any) => { - e.stopPropagation(); - this.onChange({ value }); - // eslint-disable-next-line react/no-unused-state - this.setState({ value }, () => this.onEnd(true)); - }; - - getSliderStart() { - const slider = this.sliderRef; - const { vertical, reverse } = this.props; - const rect = slider.getBoundingClientRect(); - if (vertical) { - return reverse ? rect.bottom : rect.top; - } - return window.pageXOffset + (reverse ? rect.right : rect.left); - } - - getSliderLength() { - const slider = this.sliderRef; - if (!slider) { - return 0; - } - - const coords = slider.getBoundingClientRect(); - return this.props.vertical ? coords.height : coords.width; - } - - addDocumentTouchEvents() { - // just work for Chrome iOS Safari and Android Browser - this.onTouchMoveListener = addEventListener(this.document, 'touchmove', this.onTouchMove); - this.onTouchUpListener = addEventListener(this.document, 'touchend', this.onEnd); - } - - addDocumentMouseEvents() { - this.onMouseMoveListener = addEventListener(this.document, 'mousemove', this.onMouseMove); - this.onMouseUpListener = addEventListener(this.document, 'mouseup', this.onEnd); - } - - removeDocumentEvents() { - /* eslint-disable @typescript-eslint/no-unused-expressions */ - this.onTouchMoveListener && this.onTouchMoveListener.remove(); - this.onTouchUpListener && this.onTouchUpListener.remove(); - - this.onMouseMoveListener && this.onMouseMoveListener.remove(); - this.onMouseUpListener && this.onMouseUpListener.remove(); - /* eslint-enable no-unused-expressions */ - } - - focus() { - if (this.props.disabled) { - return; - } - this.handlesRefs[0]?.focus(); - } - - blur() { - if (this.props.disabled) { - return; - } - Object.keys(this.handlesRefs).forEach((key) => { - this.handlesRefs[key]?.blur?.(); - }); - } - - calcValue(offset: number) { - const { vertical, min, max } = this.props; - const ratio = Math.abs(Math.max(offset, 0) / this.getSliderLength()); - const value = vertical ? (1 - ratio) * (max - min) + min : ratio * (max - min) + min; - return value; - } - - calcValueByPos(position: number) { - const sign = this.props.reverse ? -1 : +1; - const pixelOffset = sign * (position - this.getSliderStart()); - const nextValue = this.trimAlignValue(this.calcValue(pixelOffset)); - return nextValue; - } - - calcOffset(value: number) { - const { min, max } = this.props; - const ratio = (value - min) / (max - min); - return Math.max(0, ratio * 100); - } - - saveSlider = (slider: HTMLDivElement) => { - this.sliderRef = slider; - }; - - saveHandle(index: number, handle: any) { - this.handlesRefs[index] = handle; - } - - render() { - const { - prefixCls, - className, - marks, - dots, - step, - included, - disabled, - vertical, - reverse, - min, - max, - children, - maximumTrackStyle, - style, - railStyle, - dotStyle, - activeDotStyle, - } = this.props; - const { tracks, handles } = super.render() as any; - - const sliderClassName = classNames(prefixCls, { - [`${prefixCls}-with-marks`]: Object.keys(marks).length, - [`${prefixCls}-disabled`]: disabled, - [`${prefixCls}-vertical`]: vertical, - [className]: className, - }); - return ( -
-
- {tracks} - - {handles} - - {children} -
- ); - } - }; -} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 000000000..146b2fa78 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,30 @@ +import * as React from 'react'; +import type { AriaValueFormat, Direction } from './interface'; + +export interface SliderContextProps { + min: number; + max: number; + includedStart: number; + includedEnd: number; + direction: Direction; + disabled?: boolean; + included?: boolean; + step: number | null; + range?: boolean; + tabIndex: number | number[]; + ariaLabelForHandle?: string | string[]; + ariaLabelledByForHandle?: string | string[]; + ariaValueTextFormatterForHandle?: AriaValueFormat | AriaValueFormat[]; +} + +const SliderContext = React.createContext({ + min: 0, + max: 0, + direction: 'ltr', + step: 1, + includedStart: 0, + includedEnd: 0, + tabIndex: 0, +}); + +export default SliderContext; diff --git a/src/createSliderWithTooltip.tsx b/src/createSliderWithTooltip.tsx deleted file mode 100644 index c068fe125..000000000 --- a/src/createSliderWithTooltip.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import Tooltip from './common/SliderTooltip'; -import Handle from './Handle'; -import type { GenericSliderProps } from './interface'; - -export interface ComponentWrapperProps { - tipFormatter?: (value: number) => React.ReactNode; - tipProps?: { - prefixCls?: string; - overlay?: string; - placement?: string; - visible?: boolean; - }; - getTooltipContainer?: () => HTMLElement; -} - -interface ComponentWrapperState { - visibles: Record; -} - -export default function createSliderWithTooltip( - Component: React.ComponentClass, -) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - return class ComponentWrapper extends React.Component< - ComponentWrapperProps & React.ComponentProps, - ComponentWrapperState - > { - static defaultProps = { - tipFormatter(value: number) { - return value; - }, - handleStyle: [{}], - tipProps: {}, - getTooltipContainer: node => node.parentNode, - }; - - state = { - visibles: {}, - }; - - handleTooltipVisibleChange = (index, visible) => { - this.setState(prevState => { - return { - visibles: { - ...prevState.visibles, - [index]: visible, - }, - }; - }); - }; - - handleWithTooltip = ({ value, dragging, index, disabled, ...restProps }) => { - const { tipFormatter, tipProps, handleStyle, getTooltipContainer } = this.props; - - const { - prefixCls = 'rc-slider-tooltip', - overlay = tipFormatter(value), - placement = 'top', - visible = false, - ...restTooltipProps - } = tipProps; - - let handleStyleWithIndex; - if (Array.isArray(handleStyle)) { - handleStyleWithIndex = handleStyle[index] || handleStyle[0]; - } else { - handleStyleWithIndex = handleStyle; - } - - return ( - - this.handleTooltipVisibleChange(index, true)} - onMouseLeave={() => this.handleTooltipVisibleChange(index, false)} - /> - - ); - }; - - render() { - return ; - } - }; -} diff --git a/src/hooks/useDrag.ts b/src/hooks/useDrag.ts new file mode 100644 index 000000000..99fb590df --- /dev/null +++ b/src/hooks/useDrag.ts @@ -0,0 +1,173 @@ +import * as React from 'react'; +import type { Direction, OnStartMove } from '../interface'; +import type { OffsetValues } from './useOffset'; + +function getPosition(e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) { + const obj = 'touches' in e ? e.touches[0] : e; + + return { pageX: obj.pageX, pageY: obj.pageY }; +} + +export default function useDrag( + containerRef: React.RefObject, + direction: Direction, + rawValues: number[], + min: number, + max: number, + formatValue: (value: number) => number, + triggerChange: (values: number[]) => void, + finishChange: () => void, + offsetValues: OffsetValues, +): [number, number, number[], OnStartMove] { + const [draggingValue, setDraggingValue] = React.useState(null); + const [draggingIndex, setDraggingIndex] = React.useState(-1); + const [cacheValues, setCacheValues] = React.useState(rawValues); + const [originValues, setOriginValues] = React.useState(rawValues); + + const mouseMoveEventRef = React.useRef<(event: MouseEvent) => void>(null); + const mouseUpEventRef = React.useRef<(event: MouseEvent) => void>(null); + + React.useEffect(() => { + if (draggingIndex === -1) { + setCacheValues(rawValues); + } + }, [rawValues, draggingIndex]); + + // Clean up event + React.useEffect( + () => () => { + document.removeEventListener('mousemove', mouseMoveEventRef.current); + document.removeEventListener('mouseup', mouseUpEventRef.current); + document.removeEventListener('touchmove', mouseMoveEventRef.current); + document.removeEventListener('touchend', mouseUpEventRef.current); + }, + [], + ); + + const flushValues = (nextValues: number[], nextValue?: number) => { + // Perf: Only update state when value changed + if (cacheValues.some((val, i) => val !== nextValues[i])) { + if (nextValue !== undefined) { + setDraggingValue(nextValue); + } + setCacheValues(nextValues); + triggerChange(nextValues); + } + }; + + const updateCacheValue = (valueIndex: number, offsetPercent: number) => { + // Basic point offset + + if (valueIndex === -1) { + // >>>> Dragging on the track + const startValue = originValues[0]; + const endValue = originValues[originValues.length - 1]; + const maxStartOffset = min - startValue; + const maxEndOffset = max - endValue; + + // Get valid offset + let offset = offsetPercent * (max - min); + offset = Math.max(offset, maxStartOffset); + offset = Math.min(offset, maxEndOffset); + + // Use first value to revert back of valid offset (like steps marks) + const formatStartValue = formatValue(startValue + offset); + offset = formatStartValue - startValue; + const cloneCacheValues = originValues.map((val) => val + offset); + flushValues(cloneCacheValues); + } else { + // >>>> Dragging on the handle + const offsetDist = (max - min) * offsetPercent; + + // Always start with the valueIndex origin value + const cloneValues = [...cacheValues]; + cloneValues[valueIndex] = originValues[valueIndex]; + + const next = offsetValues(cloneValues, offsetDist, valueIndex, 'dist'); + + flushValues(next.values, next.value); + } + }; + + // Resolve closure + const updateCacheValueRef = React.useRef(updateCacheValue); + updateCacheValueRef.current = updateCacheValue; + + const onStartMove: OnStartMove = (e, valueIndex) => { + e.preventDefault(); + e.stopPropagation(); + + const originValue = rawValues[valueIndex]; + + setDraggingIndex(valueIndex); + setDraggingValue(originValue); + setOriginValues(rawValues); + + const { pageX: startX, pageY: startY } = getPosition(e); + (e.target as HTMLDivElement).focus(); + + // Moving + const onMouseMove = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + + const { pageX: moveX, pageY: moveY } = getPosition(event); + const offsetX = moveX - startX; + const offsetY = moveY - startY; + + const { width, height } = containerRef.current.getBoundingClientRect(); + + let offSetPercent: number; + switch (direction) { + case 'btt': + offSetPercent = -offsetY / height; + break; + + case 'ttb': + offSetPercent = offsetY / height; + break; + + case 'rtl': + offSetPercent = -offsetX / width; + break; + + default: + offSetPercent = offsetX / width; + } + updateCacheValueRef.current(valueIndex, offSetPercent); + }; + + // End + const onMouseUp = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + + document.removeEventListener('mouseup', onMouseUp); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('touchend', onMouseUp); + document.removeEventListener('touchmove', onMouseMove); + mouseMoveEventRef.current = null; + mouseUpEventRef.current = null; + + setDraggingIndex(-1); + finishChange(); + }; + + document.addEventListener('mouseup', onMouseUp); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('touchend', onMouseUp); + document.addEventListener('touchmove', onMouseMove); + mouseMoveEventRef.current = onMouseMove; + mouseUpEventRef.current = onMouseUp; + }; + + // Only return cache value when it mapping with rawValues + const returnValues = React.useMemo(() => { + const sourceValues = [...rawValues].sort((a, b) => a - b); + const targetValues = [...cacheValues].sort((a, b) => a - b); + + return sourceValues.every((val, index) => val === targetValues[index]) + ? cacheValues + : rawValues; + }, [rawValues, cacheValues]); + + return [draggingIndex, draggingValue, returnValues, onStartMove]; +} diff --git a/src/hooks/useOffset.ts b/src/hooks/useOffset.ts new file mode 100644 index 000000000..ae2872e01 --- /dev/null +++ b/src/hooks/useOffset.ts @@ -0,0 +1,256 @@ +import * as React from 'react'; +import type { InternalMarkObj } from '../Marks'; + +/** Format the value in the range of [min, max] */ +type FormatRangeValue = (value: number) => number; + +/** Format value align with step */ +type FormatStepValue = (value: number) => number; + +/** Format value align with step & marks */ +type FormatValue = (value: number) => number; + +type OffsetMode = 'unit' | 'dist'; + +type OffsetValue = ( + values: number[], + offset: number | 'min' | 'max', + valueIndex: number, + mode?: OffsetMode, +) => number; + +export type OffsetValues = ( + values: number[], + offset: number | 'min' | 'max', + valueIndex: number, + mode?: OffsetMode, +) => { + value: number; + values: number[]; +}; + +export default function useOffset( + min: number, + max: number, + step: number, + markList: InternalMarkObj[], + allowCross: boolean, + pushable: false | number, +): [FormatValue, OffsetValues] { + const formatRangeValue: FormatRangeValue = React.useCallback( + (val) => { + let formatNextValue = isFinite(val) ? val : min; + formatNextValue = Math.min(max, val); + formatNextValue = Math.max(min, formatNextValue); + + return formatNextValue; + }, + [min, max], + ); + + const formatStepValue: FormatStepValue = React.useCallback( + (val) => { + if (step !== null) { + return min + Math.round((formatRangeValue(val) - min) / step) * step; + } + return null; + }, + [step, min, formatRangeValue], + ); + + const formatValue: FormatValue = React.useCallback( + (val) => { + const formatNextValue = formatRangeValue(val); + + // List align values + const alignValues = markList.map((mark) => mark.value); + if (step !== null) { + alignValues.push(formatStepValue(val)); + } + + // Align with marks + let closeValue = alignValues[0]; + let closeDist = max - min; + + alignValues.forEach((alignValue) => { + const dist = Math.abs(formatNextValue - alignValue); + if (dist <= closeDist) { + closeValue = alignValue; + closeDist = dist; + } + }); + + return closeValue; + }, + [min, max, markList, step, formatRangeValue, formatStepValue], + ); + + // ========================== Offset ========================== + // Single Value + const offsetValue: OffsetValue = (values, offset, valueIndex, mode = 'unit') => { + if (typeof offset === 'number') { + let nextValue: number; + const originValue = values[valueIndex]; + + // Used for `dist` mode + const targetDistValue = originValue + offset; + + // Compare next step value & mark value which is best match + let potentialValues: number[] = []; + markList.forEach((mark) => { + potentialValues.push(mark.value); + }); + + // In case origin value is align with mark but not with step + potentialValues.push(formatStepValue(originValue)); + + // Put offset step value also + const sign = offset > 0 ? 1 : -1; + + if (mode === 'unit') { + potentialValues.push(formatStepValue(originValue + sign * step)); + } else { + potentialValues.push(formatStepValue(targetDistValue)); + } + + // Find close one + potentialValues = potentialValues + .filter((val) => val !== null) + // Remove reverse value + .filter((val) => (offset < 0 ? val <= originValue : val >= originValue)); + + if (mode === 'unit') { + // `unit` mode can not contain itself + potentialValues = potentialValues.filter((val) => val !== originValue); + } + + const compareValue = mode === 'unit' ? originValue : targetDistValue; + + nextValue = potentialValues[0]; + let valueDist = Math.abs(nextValue - compareValue); + + potentialValues.forEach((potentialValue) => { + const dist = Math.abs(potentialValue - compareValue); + if (dist < valueDist) { + nextValue = potentialValue; + valueDist = dist; + } + }); + + // Out of range will back to range + if (nextValue === undefined) { + return offset < 0 ? min : max; + } + + // `dist` mode + if (mode === 'dist') { + return nextValue; + } + + // `unit` mode may need another round + if (Math.abs(offset) > 1) { + const cloneValues = [...values]; + cloneValues[valueIndex] = nextValue; + + return offsetValue(cloneValues, offset - sign, valueIndex, mode); + } + + return nextValue; + } else if (offset === 'min') { + return min; + } else if (offset === 'max') { + return max; + } + }; + + /** Same as `offsetValue` but return `changed` mark to tell value changed */ + const offsetChangedValue = ( + values: number[], + offset: number, + valueIndex: number, + mode: OffsetMode = 'unit', + ) => { + const originValue = values[valueIndex]; + const nextValue = offsetValue(values, offset, valueIndex, mode); + return { + value: nextValue, + changed: nextValue !== originValue, + }; + }; + + const needPush = (dist: number) => { + return (pushable === null && dist === 0) || (typeof pushable === 'number' && dist < pushable); + }; + + // Values + const offsetValues: OffsetValues = (values, offset, valueIndex, mode = 'unit') => { + const nextValues = values.map(formatValue); + const originValue = nextValues[valueIndex]; + const nextValue = offsetValue(nextValues, offset, valueIndex, mode); + nextValues[valueIndex] = nextValue; + + if (allowCross === false) { + // >>>>> Allow Cross + const pushNum = pushable || 0; + + // ============ AllowCross =============== + if (valueIndex > 0 && nextValues[valueIndex - 1] !== originValue) { + nextValues[valueIndex] = Math.max( + nextValues[valueIndex], + nextValues[valueIndex - 1] + pushNum, + ); + } + + if (valueIndex < nextValues.length - 1 && nextValues[valueIndex + 1] !== originValue) { + nextValues[valueIndex] = Math.min( + nextValues[valueIndex], + nextValues[valueIndex + 1] - pushNum, + ); + } + } else if (typeof pushable === 'number' || pushable === null) { + // >>>>> Pushable + // =============== Push ================== + + // >>>>>> Basic push + // End values + for (let i = valueIndex + 1; i < nextValues.length; i += 1) { + let changed = true; + while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { + ({ value: nextValues[i], changed } = offsetChangedValue(nextValues, 1, i)); + } + } + + // Start values + for (let i = valueIndex; i > 0; i -= 1) { + let changed = true; + while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { + ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); + } + } + + // >>>>> Revert back to safe push range + // End to Start + for (let i = nextValues.length - 1; i > 0; i -= 1) { + let changed = true; + while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { + ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); + } + } + + // Start to End + for (let i = 0; i < nextValues.length - 1; i += 1) { + let changed = true; + while (needPush(nextValues[i + 1] - nextValues[i]) && changed) { + ({ value: nextValues[i + 1], changed } = offsetChangedValue(nextValues, 1, i + 1)); + } + } + } + + return { + value: nextValues[valueIndex], + values: nextValues, + }; + }; + + return [formatValue, offsetValues]; +} diff --git a/src/index.tsx b/src/index.tsx index 625c6973c..d5f06fb46 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,27 +1,6 @@ -import Slider, { SliderProps } from './Slider'; -import Range, { RangeProps } from './Range'; -import Handle, { HandleProps } from './Handle'; -import createSliderWithTooltip from './createSliderWithTooltip'; -import SliderTooltip from './common/SliderTooltip'; +import Slider from './Slider'; +import type { SliderProps } from './Slider'; -interface CompoundedComponent extends React.ComponentClass { - Range: typeof Range; - Handle: typeof Handle; - createSliderWithTooltip: typeof createSliderWithTooltip; -} +export type { SliderProps }; -const InternalSlider = (Slider as unknown) as CompoundedComponent; - -InternalSlider.Range = Range; -InternalSlider.Handle = Handle; -InternalSlider.createSliderWithTooltip = createSliderWithTooltip; -export default InternalSlider; -export { - SliderProps, - Range, - RangeProps, - Handle, - HandleProps, - createSliderWithTooltip, - SliderTooltip, -}; +export default Slider; diff --git a/src/interface.ts b/src/interface.ts index 50f5a24f7..3bee691c3 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,69 +1,7 @@ -import type * as React from 'react'; +import type React from 'react'; -export interface GenericSliderProps { - min?: number; - max?: number; - step?: number | null; - prefixCls?: string; - vertical?: boolean; - included?: boolean; - disabled?: boolean; - reverse?: boolean; - trackStyle?: React.CSSProperties | React.CSSProperties[]; - handleStyle?: React.CSSProperties | React.CSSProperties[]; - autoFocus?: boolean; - onFocus?: (e: React.FocusEvent) => void; - onBlur?: (e: React.FocusEvent) => void; - className?: string; - marks?: Record; - dots?: boolean; - maximumTrackStyle?: React.CSSProperties; - style?: React.CSSProperties; - railStyle?: React.CSSProperties; - dotStyle?: React.CSSProperties; - activeDotStyle?: React.CSSProperties; - draggableTrack?: boolean; -} +export type Direction = 'rtl' | 'ltr' | 'ttb' | 'btt'; -export interface GenericSliderState { - value?: any; - bounds?: number[]; -} +export type OnStartMove = (e: React.MouseEvent | React.TouchEvent, valueIndex: number) => void; -export interface GenericSliderClass extends React.Component { - onStart: (position: number) => void; - - positionGetValue: (pos: number) => number[]; - - onEnd: (force?: boolean) => void; - - onMove: ( - e: React.MouseEvent | React.TouchEvent, - position: number, - inTrack: boolean, - b: number[] - ) => void; - - onKeyboard: (e: React.KeyboardEvent) => void; - - onChange: (state: { value: any }) => void; - - trimAlignValue: (v: number, nextProps?: Partial) => number; - - getUpperBound: () => number; - - getLowerBound: () => number; -} - -export interface GenericSlider - extends Pick< - React.ComponentClass, - | 'displayName' - | 'defaultProps' - | 'propTypes' - | 'contextType' - | 'contextTypes' - | 'childContextTypes' - > { - new (props: Props, context?: any): GenericSliderClass; -} +export type AriaValueFormat = (value: number) => string; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 000000000..e22720319 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,40 @@ +import type { Direction } from './interface'; + +export function getOffset(value: number, min: number, max: number) { + return (value - min) / (max - min); +} + +export function getDirectionStyle(direction: Direction, value: number, min: number, max: number) { + const offset = getOffset(value, min, max); + + const positionStyle: React.CSSProperties = {}; + + switch (direction) { + case 'rtl': + positionStyle.right = `${offset * 100}%`; + positionStyle.transform = 'translateX(50%)'; + break; + + case 'btt': + positionStyle.bottom = `${offset * 100}%`; + positionStyle.transform = 'translateY(50%)'; + break; + + case 'ttb': + positionStyle.top = `${offset * 100}%`; + positionStyle.transform = 'translateY(-50%)'; + break; + + default: + positionStyle.left = `${offset * 100}%`; + positionStyle.transform = 'translateX(-50%)'; + break; + } + + return positionStyle; +} + +/** Return index value if is list or return value directly */ +export function getIndex(value: T | T[], index: number) { + return Array.isArray(value) ? value[index] : value; +} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index f1d95f122..000000000 --- a/src/utils.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { findDOMNode } from 'react-dom'; -import keyCode from 'rc-util/lib/KeyCode'; - -export function isEventFromHandle( - e: { target: HTMLElement }, - handles: Record, -) { - try { - return Object.keys(handles).some(key => e.target === findDOMNode(handles[key])); - } catch (error) { - return false; - } -} - -export function isValueOutOfRange(value: number, { min, max }: { min?: number; max?: number }) { - return value < min || value > max; -} - -export function isNotTouchEvent(e: React.TouchEvent) { - return e.touches.length > 1 || (e.type.toLowerCase() === 'touchend' && e.touches.length > 0); -} - -export function getClosestPoint(val: number, { marks, step, min, max }) { - const points = Object.keys(marks).map(parseFloat); - if (step !== null) { - const baseNum = 10 ** getPrecision(step); - const maxSteps = Math.floor((max * baseNum - min * baseNum) / (step * baseNum)); - const steps = Math.min((val - min) / step, maxSteps); - const closestStep = Math.round(steps) * step + min; - points.push(closestStep); - } - const diffs = points.map(point => Math.abs(val - point)); - return points[diffs.indexOf(Math.min(...diffs))]; -} - -export function getPrecision(step: number) { - const stepString = step.toString(); - let precision = 0; - if (stepString.indexOf('.') >= 0) { - precision = stepString.length - stepString.indexOf('.') - 1; - } - return precision; -} - -export function getMousePosition(vertical: boolean, e: React.MouseEvent) { - return vertical ? e.clientY : e.pageX; -} - -export function getTouchPosition(vertical: boolean, e: React.TouchEvent) { - return vertical ? e.touches[0].clientY : e.touches[0].pageX; -} - -export function getHandleCenterPosition(vertical: boolean, handle: HTMLElement) { - const coords = handle.getBoundingClientRect(); - return vertical - ? coords.top + coords.height * 0.5 - : window.pageXOffset + coords.left + coords.width * 0.5; -} - -export function ensureValueInRange(val: number, { max, min }: { max?: number; min?: number }) { - if (val <= min) { - return min; - } - if (val >= max) { - return max; - } - return val; -} - -export function ensureValuePrecision(val: number, props) { - const { step } = props; - const closestPoint = isFinite(getClosestPoint(val, props)) ? getClosestPoint(val, props) : 0; // eslint-disable-line - return step === null ? closestPoint : parseFloat(closestPoint.toFixed(getPrecision(step))); -} - -export function pauseEvent(e: React.SyntheticEvent) { - e.stopPropagation(); - e.preventDefault(); -} - -export function calculateNextValue(func, value, props) { - const operations = { - increase: (a, b) => a + b, - decrease: (a, b) => a - b, - }; - - const indexToGet = operations[func](Object.keys(props.marks).indexOf(JSON.stringify(value)), 1); - const keyToGet = Object.keys(props.marks)[indexToGet]; - - if (props.step) { - return operations[func](value, props.step); - } - if (!!Object.keys(props.marks).length && !!props.marks[keyToGet]) { - return props.marks[keyToGet]; - } - return value; -} - -export function getKeyboardValueMutator( - e: React.KeyboardEvent, - vertical: boolean, - reverse: boolean, -) { - const increase = 'increase'; - const decrease = 'decrease'; - let method = increase; - switch (e.keyCode) { - case keyCode.UP: - method = vertical && reverse ? decrease : increase; - break; - case keyCode.RIGHT: - method = !vertical && reverse ? decrease : increase; - break; - case keyCode.DOWN: - method = vertical && reverse ? increase : decrease; - break; - case keyCode.LEFT: - method = !vertical && reverse ? increase : decrease; - break; - - case keyCode.END: - return (value, props) => props.max; - case keyCode.HOME: - return (value, props) => props.min; - case keyCode.PAGE_UP: - return (value, props) => value + props.step * 2; - case keyCode.PAGE_DOWN: - return (value, props) => value - props.step * 2; - - default: - return undefined; - } - return (value, props) => calculateNextValue(method, value, props); -} diff --git a/tests/Range.test.js b/tests/Range.test.js index e08b58246..37ba64885 100644 --- a/tests/Range.test.js +++ b/tests/Range.test.js @@ -1,706 +1,496 @@ /* eslint-disable max-len, no-undef, react/no-string-refs, no-param-reassign, max-classes-per-file */ import React from 'react'; -import { render, mount } from 'enzyme'; import keyCode from 'rc-util/lib/KeyCode'; -import Range from '../src/Range'; -import createSliderWithTooltip from '../src/createSliderWithTooltip'; - -const RangeWithTooltip = createSliderWithTooltip(Range); +import { render, fireEvent, createEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; +import Slider from '../src/'; +import { resetWarned } from 'rc-util/lib/warning'; describe('Range', () => { + let container; + + beforeAll(() => { + spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ + width: 100, + height: 100, + }), + }); + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function doMouseMove(container, start, end, element = 'rc-slider-handle') { + const mouseDown = createEvent.mouseDown(container.getElementsByClassName(element)[0]); + mouseDown.pageX = start; + mouseDown.pageY = start; + fireEvent(container.getElementsByClassName(element)[0], mouseDown); + + // Drag + const mouseMove = createEvent.mouseMove(document); + mouseMove.pageX = end; + mouseMove.pageY = end; + fireEvent(document, mouseMove); + } + + function doTouchMove(container, start, end, element = 'rc-slider-handle') { + const touchStart = createEvent.touchStart(container.getElementsByClassName(element)[0], { + touches: [{}], + }); + touchStart.touches[0].pageX = start; + fireEvent(container.getElementsByClassName(element)[0], touchStart); + + // Drag + const touchMove = createEvent.touchMove(document, { + touches: [{}], + }); + touchMove.touches[0].pageX = end; + fireEvent(document, touchMove); + } + it('should render Range with correct DOM structure', () => { - const wrapper = render(); - expect(wrapper).toMatchSnapshot(); + const { asFragment } = render(); + expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render Multi-Range with correct DOM structure', () => { - const wrapper = render(); - expect(wrapper).toMatchSnapshot(); + const { asFragment } = render(); + expect(asFragment().firstChild).toMatchSnapshot(); }); - it('should render Range with value correctly', () => { - const wrapper = mount(); - expect(wrapper.state('bounds')[0]).toBe(0); - expect(wrapper.state('bounds')[1]).toBe(50); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(0) - .props().style.left, - ).toMatch('0%'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .props().style.left, - ).toMatch('50%'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(0) - .props().style.right, - ).toMatch('auto'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .props().style.right, - ).toMatch('auto'); - - const trackStyle = wrapper - .find('.rc-slider-track > .rc-slider-track') - .at(0) - .props().style; - expect(trackStyle.left).toMatch('0%'); - expect(trackStyle.right).toMatch('auto'); - expect(trackStyle.width).toMatch('50%'); + it('should render Range with value correctly', async () => { + const { container } = render(); + + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle('left: 0%'); + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveStyle('left: 50%'); + + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle( + 'left: 0%; width: 50%', + ); }); it('should render reverse Range with value correctly', () => { - const wrapper = mount(); - expect(wrapper.state('bounds')[0]).toBe(0); - expect(wrapper.state('bounds')[1]).toBe(50); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(0) - .props().style.right, - ).toMatch('0%'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .props().style.right, - ).toMatch('50%'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(0) - .props().style.left, - ).toMatch('auto'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .props().style.left, - ).toMatch('auto'); - - const trackStyle = wrapper - .find('.rc-slider-track > .rc-slider-track') - .at(0) - .props().style; - expect(trackStyle.right).toMatch('0%'); - expect(trackStyle.left).toMatch('auto'); - expect(trackStyle.width).toMatch('50%'); + const { container } = render(); + + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle('right: 0%'); + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveStyle('right: 50%'); + + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle( + 'right: 0%; width: 50%', + ); }); it('should render Range with tabIndex correctly', () => { - const wrapper = mount(); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(0) - .props().tabIndex, - ).toEqual(1); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .props().tabIndex, - ).toEqual(2); + const { container } = render(); + + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'tabIndex', + '1', + ); + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( + 'tabIndex', + '2', + ); }); it('should render Range without tabIndex (equal null) correctly', () => { - const wrapper = mount(); - const firstHandle = wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(0) - .getDOMNode(); - const secondHandle = wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .getDOMNode(); - expect(firstHandle.hasAttribute('tabIndex')).toEqual(false); - expect(secondHandle.hasAttribute('tabIndex')).toEqual(false); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).not.toHaveAttribute('tabIndex'); + expect(container.getElementsByClassName('rc-slider-handle')[1]).not.toHaveAttribute('tabIndex'); }); it('it should trigger onAfterChange when key pressed', () => { const onAfterChange = jest.fn(); - const wrapper = mount(); + const { container } = render( + , + ); - const secondHandle = wrapper.find('.rc-slider-handle > .rc-slider-handle').at(1); - wrapper.simulate('focus'); - secondHandle.simulate('keyDown', { keyCode: keyCode.RIGHT }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.RIGHT, + }); expect(onAfterChange).toBeCalled(); }); it('should render Multi-Range with value correctly', () => { - const wrapper = mount(); - expect(wrapper.state('bounds')[0]).toBe(0); - expect(wrapper.state('bounds')[1]).toBe(25); - expect(wrapper.state('bounds')[2]).toBe(50); - expect(wrapper.state('bounds')[3]).toBe(75); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(0) - .props().style.left, - ).toMatch('0%'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .props().style.right, - ).toMatch('auto'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .props().style.right, - ).toMatch('auto'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(1) - .props().style.left, - ).toMatch('25%'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(2) - .props().style.left, - ).toMatch('50%'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(2) - .props().style.right, - ).toMatch('auto'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(3) - .props().style.left, - ).toMatch('75%'); - expect( - wrapper - .find('.rc-slider-handle > .rc-slider-handle') - .at(3) - .props().style.right, - ).toMatch('auto'); - - const track1Style = wrapper - .find('.rc-slider-track > .rc-slider-track') - .at(0) - .props().style; - expect(track1Style.left).toMatch('0%'); - expect(track1Style.right).toMatch('auto'); - expect(track1Style.width).toMatch('25%'); - - const track2Style = wrapper - .find('.rc-slider-track > .rc-slider-track') - .at(1) - .props().style; - expect(track2Style.left).toMatch('25%'); - expect(track2Style.right).toMatch('auto'); - expect(track2Style.width).toMatch('25%'); - - const track3Style = wrapper - .find('.rc-slider-track > .rc-slider-track') - .at(2) - .props().style; - expect(track3Style.left).toMatch('50%'); - expect(track3Style.right).toMatch('auto'); - expect(track3Style.width).toMatch('25%'); - }); + const { container } = render(); - it('should update Range correctly in controllered model', () => { - class TestParent extends React.Component { - // eslint-disable-line - state = { - value: [2, 4, 6], - }; + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle('left: 0%'); + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveStyle('left: 25%'); + expect(container.getElementsByClassName('rc-slider-handle')[2]).toHaveStyle('left: 50%'); + expect(container.getElementsByClassName('rc-slider-handle')[3]).toHaveStyle('left: 75%'); - getSlider() { - return this.refs.slider; - } + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle( + 'left: 0%; width: 25%', + ); - render() { - return ; - } - } - const wrapper = mount(); + expect(container.getElementsByClassName('rc-slider-track')[1]).toHaveStyle( + 'left: 25%; width: 25%', + ); - expect(wrapper.instance().getSlider().state.bounds.length).toBe(3); - expect(wrapper.find('.rc-slider-handle > .rc-slider-handle').length).toBe(3); - wrapper.setState({ value: [2, 4] }); - expect(wrapper.instance().getSlider().state.bounds.length).toBe(2); - expect(wrapper.find('.rc-slider-handle > .rc-slider-handle').length).toBe(2); + expect(container.getElementsByClassName('rc-slider-track')[2]).toHaveStyle( + 'left: 50%; width: 25%', + ); }); - it('should only update bounds that are out of range', () => { - const props = { min: 0, max: 10000, value: [0.01, 10000], onChange: jest.fn() }; - const range = mount(); - range.setProps({ min: 0, max: 500 }); + it('should update Range correctly in controlled model', () => { + const { container, rerender } = render(); + expect(container.getElementsByClassName('rc-slider-handle')).toHaveLength(3); - expect(props.onChange).toHaveBeenCalledWith([0.01, 500]); + rerender(); + expect(container.getElementsByClassName('rc-slider-handle')).toHaveLength(2); }); - it('should only update bounds if they are out of range', () => { - const props = { min: 0, max: 10000, value: [0.01, 10000], onChange: jest.fn() }; - const range = mount(); - range.setProps({ min: 0, max: 500, value: [0.01, 466] }); + // Not trigger onChange anymore + // it('should only update bounds that are out of range', () => { + // const props = { min: 0, max: 10000, value: [0.01, 10000], onChange: jest.fn() }; + // const range = mount(); + // range.setProps({ min: 0, max: 500 }); - expect(props.onChange).toHaveBeenCalledTimes(0); - }); + // expect(props.onChange).toHaveBeenCalledWith([0.01, 500]); + // }); + + // Not trigger onChange anymore + // it('should only update bounds if they are out of range', () => { + // const props = { min: 0, max: 10000, value: [0.01, 10000], onChange: jest.fn() }; + // const range = mount(); + // range.setProps({ min: 0, max: 500, value: [0.01, 466] }); + + // expect(props.onChange).toHaveBeenCalledTimes(0); + // }); // https://github.com/react-component/slider/pull/256 - it('should handle mutli handle mouseEnter correctly', () => { - const wrapper = mount(); - wrapper - .find('.rc-slider-handle') - .at(1) - .simulate('mouseEnter'); - expect(wrapper.state().visibles[0]).toBe(true); - wrapper - .find('.rc-slider-handle') - .at(3) - .simulate('mouseEnter'); - expect(wrapper.state().visibles[1]).toBe(true); - wrapper - .find('.rc-slider-handle') - .at(1) - .simulate('mouseLeave'); - expect(wrapper.state().visibles[0]).toBe(false); - wrapper - .find('.rc-slider-handle') - .at(3) - .simulate('mouseLeave'); - expect(wrapper.state().visibles[1]).toBe(false); - }); + // Move to antd instead + // it('should handle multi handle mouseEnter correctly', () => { + // const wrapper = mount(); + // wrapper.find('.rc-slider-handle').at(1).simulate('mouseEnter'); + // expect(wrapper.state().visibles[0]).toBe(true); + // wrapper.find('.rc-slider-handle').at(3).simulate('mouseEnter'); + // expect(wrapper.state().visibles[1]).toBe(true); + // wrapper.find('.rc-slider-handle').at(1).simulate('mouseLeave'); + // expect(wrapper.state().visibles[0]).toBe(false); + // wrapper.find('.rc-slider-handle').at(3).simulate('mouseLeave'); + // expect(wrapper.state().visibles[1]).toBe(false); + // }); + + it('should keep pushable when not allowCross', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); - it('should keep pushable when not allowCross and setState', () => { - class CustomizedRange extends React.Component { - // eslint-disable-line - constructor(props) { - super(props); - this.state = { - value: [20, 40], - }; - } + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.UP, + }); + expect(onChange).toHaveBeenCalledWith([30, 40]); - getSlider() { - return this.refs.slider; - } + onChange.mockReset(); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.UP, + }); + expect(onChange).not.toHaveBeenCalled(); + + onChange.mockReset(); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.UP, + }); + expect(onChange).toHaveBeenCalledWith([30, 41]); - render() { - return ; - } + // Push to the edge + for (let i = 0; i < 99; i += 1) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.DOWN, + }); } - const wrapper = mount(); - expect(wrapper.instance().getSlider().state.bounds[0]).toBe(20); - expect(wrapper.instance().getSlider().state.bounds[1]).toBe(40); - wrapper.setState({ value: [30, 40] }); - expect(wrapper.instance().getSlider().state.bounds[0]).toBe(30); - expect(wrapper.instance().getSlider().state.bounds[1]).toBe(40); - wrapper.setState({ value: [35, 40] }); - expect(wrapper.instance().getSlider().state.bounds[0]).toBe(30); - expect(wrapper.instance().getSlider().state.bounds[1]).toBe(40); - wrapper.setState({ value: [30, 30] }); - expect(wrapper.instance().getSlider().state.bounds[0]).toBe(30); - expect(wrapper.instance().getSlider().state.bounds[1]).toBe(40); + expect(onChange).toHaveBeenCalledWith([30, 40]); + + onChange.mockReset(); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.DOWN, + }); + expect(onChange).not.toHaveBeenCalled(); }); - it('should render correctly when allowCross', () => { - class CustomizedRange extends React.Component { - // eslint-disable-line - constructor(props) { - super(props); - this.state = { - value: [20, 40], - }; - } + it('pushable & allowCross', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); - onChange = value => { - this.setState({ - value, - }); - }; + // Left to Right + for (let i = 0; i < 99; i += 1) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.UP, + }); + } + expect(onChange).toHaveBeenCalledWith([80, 90, 100]); - getSlider() { - return this.refs.slider; - } + // Center to Left + for (let i = 0; i < 99; i += 1) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.DOWN, + }); + } + expect(onChange).toHaveBeenCalledWith([0, 10, 100]); - render() { - return ; - } + // Right to Right + for (let i = 0; i < 99; i += 1) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[2], { + keyCode: keyCode.DOWN, + }); } - const map = {}; - document.addEventListener = jest.fn().mockImplementation((event, cb) => { - map[event] = cb; - }); + expect(onChange).toHaveBeenCalledWith([0, 10, 20]); - const mockRect = wrapper => { - wrapper.instance().getSlider().sliderRef.getBoundingClientRect = () => ({ - left: 0, - width: 100, + // Center to Right + for (let i = 0; i < 99; i += 1) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.UP, }); - }; + } + expect(onChange).toHaveBeenCalledWith([0, 90, 100]); + }); - const container = document.createElement('div'); - document.body.appendChild(container); + describe('should render correctly when allowCross', () => { + function testLTR(name, func) { + it(name, () => { + const onChange = jest.fn(); + const { container, unmount } = render( + , + ); - const wrapper = mount(, { attachTo: container }); - mockRect(wrapper); + // Do move + func(container); - expect(wrapper.instance().getSlider().state.bounds).toEqual([20, 40]); + expect(onChange).toHaveBeenCalledWith([40, 100]); - wrapper.find('.rc-slider').simulate('mouseDown', { - button: 0, - pageX: 0, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, - }); - map.mousemove({ - type: 'mousemove', - pageX: 60, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, - }); + unmount(); + }); + } - expect(wrapper.instance().getSlider().state.bounds).toEqual([40, 60]); - expect( - wrapper - .find('.rc-slider-handle-2') - .at(1) - .getDOMNode().className, - ).toContain('rc-slider-handle-dragging'); - }); + testLTR('mouse', (container) => doMouseMove(container, 0, 9999)); + testLTR('touch', (container) => doTouchMove(container, 0, 9999)); - it('should keep pushable with pushable s defalutValue when not allowCross and setState', () => { - class CustomizedRange extends React.Component { - // eslint-disable-line - state = { - value: [20, 40], - }; - - onChange = value => { - this.setState({ - value, - }); - }; - - getSlider() { - return this.slider; - } - - saveSlider = slider => { - this.slider = slider; - }; - - render() { - return ( - - ); - } - } - const map = {}; - document.addEventListener = jest.fn().mockImplementation((event, cb) => { - map[event] = cb; - }); + it('reverse', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); - const mockRect = wrapper => { - wrapper.instance().getSlider().sliderRef.getBoundingClientRect = () => ({ - left: 0, - width: 100, - }); - }; + // Do move + doMouseMove(container, 0, -10); - const container = document.createElement('div'); - document.body.appendChild(container); + expect(onChange).toHaveBeenCalledWith([30, 40]); + }); - const wrapper = mount(, { attachTo: container }); - mockRect(wrapper); + it('vertical', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); - expect(wrapper.instance().getSlider().state.bounds).toEqual([20, 40]); + // Do move + doMouseMove(container, 0, -10); - wrapper.find('.rc-slider').simulate('mouseDown', { - button: 0, - pageX: 0, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, - }); - map.mousemove({ - type: 'mousemove', - pageX: 30, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, - }); - map.mouseup({ - type: 'mouseup', - pageX: 30, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, + expect(onChange).toHaveBeenCalledWith([30, 40]); }); - expect(wrapper.instance().getSlider().state.bounds).toEqual([30, 40]); + it('vertical & reverse', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); - wrapper.find('.rc-slider').simulate('mouseDown', { - button: 0, - pageX: 0, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, - }); - map.mousemove({ - type: 'mousemove', - pageX: 50, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, - }); - map.mouseup({ - type: 'mouseup', - pageX: 50, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, + // Do move + doMouseMove(container, 0, -10); + + expect(onChange).toHaveBeenCalledWith([10, 40]); }); - expect(wrapper.instance().getSlider().state.bounds).toEqual([39, 40]); }); - it('track draggable', () => { - class CustomizedRange extends React.Component { - // eslint-disable-line - state = { - value: [0, 30], - }; - - onChange = value => { - this.setState({ - value, - }); - }; - - getSlider() { - return this.slider; - } - - saveSlider = slider => { - this.slider = slider; - }; - - render() { - return ( - - ); - } - } - const map = {}; - document.addEventListener = jest.fn().mockImplementation((event, cb) => { - map[event] = cb; - }); + describe('should keep pushable with pushable s defalutValue when not allowCross and setState', () => { + function test(name, func) { + it(name, () => { + const onChange = jest.fn(); + + const Demo = () => { + const [value, setValue] = React.useState([20, 40]); + + return ( + { + setValue(values); + onChange(values); + }} + value={[20, 40]} + allowCross={false} + pushable + /> + ); + }; - const mockRect = wrapper => { - wrapper.instance().getSlider().sliderRef.getBoundingClientRect = () => ({ - left: 0, - width: 100, + global.error = true; + const { container, unmount } = render(); + + // Do move + func(container); + + expect(onChange).toHaveBeenCalledWith([39, 40]); + + unmount(); }); - }; + } - const container = document.createElement('div'); - document.body.appendChild(container); + test('mouse', (container) => doMouseMove(container, 0, 9999)); + test('touch', (container) => doTouchMove(container, 0, 9999)); + }); - const range = mount(, { attachTo: container }); - mockRect(range); - console.log(range.state().value); - expect(range.state().value).toEqual([0, 30]); - - range.find('.rc-slider').simulate('mouseDown', { - button: 0, - pageX: 10, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, - }); - map.mousemove({ - type: 'mousemove', - pageX: 30, - pageY: 0, - stopPropagation: () => {}, - preventDefault: () => {}, - }); - console.log(range.state().value); - expect(range.state().value).toEqual([20, 50]); + describe('track draggable', () => { + function test(name, func) { + it(name, () => { + const onChange = jest.fn(); + + const { container, unmount } = render( + , + ); + + // Do move + func(container); + + expect(onChange).toHaveBeenCalledWith([20, 50]); + + unmount(); + }); + } + + test('mouse', (container) => doMouseMove(container, 0, 20, 'rc-slider-track')); + test('touch', (container) => doTouchMove(container, 0, 20, 'rc-slider-track')); }); it('sets aria-label on the handles', () => { - const wrapper = mount(); - expect( - wrapper - .find('.rc-slider-handle-1') - .at(1) - .prop('aria-label'), - ).toEqual('Some Label'); - expect( - wrapper - .find('.rc-slider-handle-2') - .at(1) - .prop('aria-label'), - ).toEqual('Some other Label'); + const { container } = render( + , + ); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'aria-label', + 'Some Label', + ); + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( + 'aria-label', + 'Some other Label', + ); }); it('sets aria-labelledby on the handles', () => { - const wrapper = mount(); - expect( - wrapper - .find('.rc-slider-handle-1') - .at(1) - .prop('aria-labelledby'), - ).toEqual('some_id'); - expect( - wrapper - .find('.rc-slider-handle-2') - .at(1) - .prop('aria-labelledby'), - ).toEqual('some_other_id'); + const { container } = render( + , + ); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'aria-labelledby', + 'some_id', + ); + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( + 'aria-labelledby', + 'some_other_id', + ); }); it('sets aria-valuetext on the handles', () => { - const wrapper = mount( - `${value} of something`, - value => `${value} of something else`, + ariaValueTextFormatterForHandle={[ + (value) => `${value} of something`, + (value) => `${value} of something else`, ]} />, ); - expect( - wrapper - .find('.rc-slider-handle-1') - .at(1) - .prop('aria-valuetext'), - ).toEqual('1 of something'); - expect( - wrapper - .find('.rc-slider-handle-2') - .at(1) - .prop('aria-valuetext'), - ).toEqual('3 of something else'); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'aria-valuetext', + '1 of something', + ); + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute( + 'aria-valuetext', + '3 of something else', + ); }); // Corresponds to the issue described in https://github.com/react-component/slider/issues/690. it('should correctly display a dynamically changed number of handles', () => { - class RangeUnderTest extends React.Component { - state = { - handles: [0, 25, 50, 75, 100], - }; - - render() { - return ( - - ); - } - } + const props = { + range: true, + allowCross: false, + marks: { + 0: { label: '0', style: {} }, + 25: { label: '25', style: {} }, + 50: { label: '50', style: {} }, + 75: { label: '75', style: {} }, + 100: { label: '100', style: {} }, + }, + step: null, + }; - const rangeUnderTest = mount(); - const verifyHandles = () => { + const { container, rerender } = render(); + + const verifyHandles = (values) => { // Has the number of handles that we set. - expect(rangeUnderTest.find('div.rc-slider-handle')).toHaveLength( - rangeUnderTest.state('handles').length, - ); + expect(container.getElementsByClassName('rc-slider-handle')).toHaveLength(values.length); + // Handles have the values that we set. - expect( - rangeUnderTest - .find('div.rc-slider-handle') - .everyWhere( - (element, index) => - Number(element.prop('aria-valuenow')) === rangeUnderTest.state('handles')[index], - ), - ).toEqual(true); + Array.from(container.getElementsByClassName('rc-slider-handle')).forEach((ele, index) => { + expect(ele).toHaveAttribute('aria-valuenow', values[index].toString()); + }); }; // Assert that handles are correct initially. - verifyHandles(); + verifyHandles([0, 25, 50, 75, 100]); // Assert that handles are correct after decreasing their number. - rangeUnderTest.setState({ handles: [0, 75, 100] }); - verifyHandles(); + rerender(); + verifyHandles([0, 75, 100]); // Assert that handles are correct after increasing their number. - rangeUnderTest.setState({ handles: [0, 25, 75, 100] }); - verifyHandles(); + rerender(); + verifyHandles([0, 25, 75, 100]); }); describe('focus & blur', () => { - let container; - - beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - }); - - afterEach(() => { - document.body.removeChild(container); - }); - - const mockRect = wrapper => { - wrapper.instance().sliderRef.getBoundingClientRect = () => ({ - left: 10, - width: 100, - }); - }; - it('focus()', () => { const handleFocus = jest.fn(); - const wrapper = mount(, { - attachTo: container, - }); - mockRect(wrapper); - wrapper.instance().focus(); + const { container } = render(); + container.getElementsByClassName('rc-slider-handle')[0].focus(); expect(handleFocus).toBeCalled(); }); it('blur', () => { const handleBlur = jest.fn(); - const wrapper = mount(, { - attachTo: container, - }); - mockRect(wrapper); - wrapper.instance().focus(); - wrapper.instance().blur(); + const { container } = render(); + container.getElementsByClassName('rc-slider-handle')[0].focus(); + container.getElementsByClassName('rc-slider-handle')[0].blur(); expect(handleBlur).toBeCalled(); }); }); + + it('warning for `draggableTrack` and `mergedStep=null`', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + resetWarned(); + render(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Warning: `draggableTrack` is not supported when `step` is `null`.', + ); + errorSpy.mockRestore(); + }); }); diff --git a/tests/Slider.test.js b/tests/Slider.test.js index 9db8ea27b..bf24b6c26 100644 --- a/tests/Slider.test.js +++ b/tests/Slider.test.js @@ -1,364 +1,562 @@ -/* eslint-disable max-len, no-undef, jsx-a11y/tabindex-no-positive */ import React from 'react'; -import { render, mount } from 'enzyme'; +import classNames from 'classnames'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; import keyCode from 'rc-util/lib/KeyCode'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; import Slider from '../src/Slider'; -import Range from '../src/Range'; describe('Slider', () => { + beforeAll(() => { + spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ + top: 0, + bottom: 100, + left: 0, + right: 100, + width: 100, + height: 100, + }), + }); + }); + it('should render Slider with correct DOM structure', () => { - const wrapper = render(); - expect(wrapper).toMatchSnapshot(); + const { asFragment } = render(); + expect(asFragment().firstChild).toMatchSnapshot(); }); it('should render Slider with value correctly', () => { - const wrapper = mount(); - expect(wrapper.state('value')).toBe(50); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.left).toMatch('50%'); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.right).toMatch('auto'); - - const trackStyle = wrapper.find('.rc-slider-track').at(1).props().style; - expect(trackStyle.left).toMatch('0%'); - expect(trackStyle.right).toMatch('auto'); - expect(trackStyle.width).toMatch('50%'); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ left: '50%' }); + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ + left: '0%', + width: '50%', + }); }); it('should render Slider correctly where value > startPoint', () => { - const wrapper = mount(); - expect(wrapper.state('value')).toBe(50); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.left).toMatch('50%'); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.right).toMatch('auto'); - - const trackStyle = wrapper.find('.rc-slider-track').at(1).props().style; - expect(trackStyle.left).toMatch('20%'); - expect(trackStyle.right).toMatch('auto'); - expect(trackStyle.width).toMatch('30%'); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ left: '50%' }); + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ + left: '20%', + width: '30%', + }); }); it('should render Slider correctly where value < startPoint', () => { - const wrapper = mount(); - expect(wrapper.state('value')).toBe(40); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.left).toMatch('40%'); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.right).toMatch('auto'); - - const trackStyle = wrapper.find('.rc-slider-track').at(1).props().style; - expect(trackStyle.left).toMatch('auto'); - expect(trackStyle.right).toMatch('40%'); - expect(trackStyle.width).toMatch('20%'); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ left: '40%' }); + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ + left: '40%', + width: '20%', + }); }); it('should render reverse Slider with value correctly', () => { - const wrapper = mount(); - expect(wrapper.state('value')).toBe(50); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.right).toMatch('50%'); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.left).toMatch('auto'); - - const trackStyle = wrapper.find('.rc-slider-track').at(1).props().style; - expect(trackStyle.right).toMatch('0%'); - expect(trackStyle.left).toMatch('auto'); - expect(trackStyle.width).toMatch('50%'); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ right: '50%' }); + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ + right: '0%', + width: '50%', + }); }); it('should render reverse Slider correctly where value > startPoint', () => { - const wrapper = mount(); - expect(wrapper.state('value')).toBe(50); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.right).toMatch('50%'); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.left).toMatch('auto'); - - const trackStyle = wrapper.find('.rc-slider-track').at(1).props().style; - expect(trackStyle.right).toMatch('20%'); - expect(trackStyle.left).toMatch('auto'); - expect(trackStyle.width).toMatch('30%'); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ right: '50%' }); + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ + right: '20%', + width: '30%', + }); }); it('should render reverse Slider correctly where value < startPoint', () => { - const wrapper = mount(); - expect(wrapper.state('value')).toBe(30); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.right).toMatch('30%'); - expect(wrapper.find('.rc-slider-handle').at(1).props().style.left).toMatch('auto'); - - const trackStyle = wrapper.find('.rc-slider-track').at(1).props().style; - expect(trackStyle.right).toMatch('auto'); - expect(trackStyle.left).toMatch('50%'); - expect(trackStyle.width).toMatch('20%'); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveStyle({ right: '30%' }); + expect(container.getElementsByClassName('rc-slider-track')[0]).toHaveStyle({ + right: '30%', + width: '20%', + }); }); it('should render reverse Slider with marks correctly', () => { - const marks = {5:'5', 6:'6', 7:'7', 8:'8', 9:'9', 10:'10'}; - const wrapper = mount(); - expect(wrapper.find('.rc-slider-mark-text').at(0).props().style.right).toMatch('0%'); + const marks = { 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10' }; + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-mark-text')[0]).toHaveStyle({ right: '0%' }); }); it('should render Slider without handle if value is null', () => { - const wrapper = render(); - expect(wrapper).toMatchSnapshot(); + const { asFragment } = render(); + expect(asFragment().firstChild).toMatchSnapshot(); }); it('should allow tabIndex to be set on Handle via Slider', () => { - const wrapper = mount(); - expect(wrapper.find('.rc-slider-handle').at(1).props().tabIndex).toEqual(1); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'tabIndex', + '1', + ); }); it('should allow tabIndex to be set on Handle via Slider and be equal null', () => { - const wrapper = mount(); - const handle = wrapper.find('.rc-slider-handle > .rc-slider-handle').at(0).getDOMNode(); - expect(handle.hasAttribute('tabIndex')).toEqual(false); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).not.toHaveAttribute('tabIndex'); }); it('increases the value when key "up" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.UP }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.UP, + }); - expect(wrapper.state('value')).toBe(51); + expect(onChange).toHaveBeenCalledWith(51); }); it('decreases the value for reverse-vertical when key "up" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.UP }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.UP, + }); - expect(wrapper.state('value')).toBe(49); + expect(onChange).toHaveBeenCalledWith(49); }); it('increases the value when key "right" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.RIGHT }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.RIGHT, + }); - expect(wrapper.state('value')).toBe(51); + expect(onChange).toHaveBeenCalledWith(51); }); it('it should trigger onAfterChange when key pressed', () => { const onAfterChange = jest.fn(); - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.RIGHT }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.RIGHT, + }); expect(onAfterChange).toBeCalled(); }); it('decreases the value for reverse-horizontal when key "right" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.RIGHT }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.RIGHT, + }); - expect(wrapper.state('value')).toBe(49); + expect(onChange).toHaveBeenCalledWith(49); }); it('increases the value when key "page up" is pressed, by a factor 2', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.PAGE_UP }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.PAGE_UP, + }); - expect(wrapper.state('value')).toBe(52); + expect(onChange).toHaveBeenCalledWith(52); }); it('decreases the value when key "down" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.DOWN }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.DOWN, + }); - expect(wrapper.state('value')).toBe(49); + expect(onChange).toHaveBeenCalledWith(49); }); it('decreases the value when key "left" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.LEFT }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.LEFT, + }); - expect(wrapper.state('value')).toBe(49); + expect(onChange).toHaveBeenCalledWith(49); }); it('it should work fine when arrow key is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); + + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.LEFT, + }); + expect(onChange).toHaveBeenCalledWith([20, 49]); - handler.simulate('keyDown', { keyCode: keyCode.LEFT }); - expect(wrapper.state('bounds')).toEqual([20, 49]); - handler.simulate('keyDown', { keyCode: keyCode.RIGHT }); - expect(wrapper.state('bounds')).toEqual([20, 50]); - handler.simulate('keyDown', { keyCode: keyCode.UP }); - expect(wrapper.state('bounds')).toEqual([20, 51]); - handler.simulate('keyDown', { keyCode: keyCode.DOWN }); - expect(wrapper.state('bounds')).toEqual([20, 50]); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.RIGHT, + }); + expect(onChange).toHaveBeenCalledWith([20, 50]); + + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.UP, + }); + expect(onChange).toHaveBeenCalledWith([20, 51]); + + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { + keyCode: keyCode.DOWN, + }); + expect(onChange).toHaveBeenCalledWith([20, 50]); }); it('decreases the value when key "page down" is pressed, by a factor 2', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.PAGE_DOWN }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.PAGE_DOWN, + }); - expect(wrapper.state('value')).toBe(48); + expect(onChange).toHaveBeenCalledWith(48); }); it('sets the value to minimum when key "home" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.HOME }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.HOME, + }); - expect(wrapper.state('value')).toBe(0); + expect(onChange).toHaveBeenCalledWith(0); }); it('sets the value to maximum when the key "end" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); + const onChange = jest.fn(); + const { container } = render(); - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.END }); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.END, + }); - expect(wrapper.state('value')).toBe(100); + expect(onChange).toHaveBeenCalledWith(100); }); describe('when component has fixed values', () => { it('increases the value when key "up" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); - - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.UP }); - - expect(wrapper.state('value')).toBe(100); + const onChange = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.UP, + }); + expect(onChange).toHaveBeenCalledWith(100); }); it('increases the value when key "right" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); - - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.RIGHT }); - - expect(wrapper.state('value')).toBe(100); + const onChange = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.RIGHT, + }); + expect(onChange).toHaveBeenCalledWith(100); }); it('decreases the value when key "down" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); - - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.DOWN }); - - expect(wrapper.state('value')).toBe(20); + const onChange = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.DOWN, + }); + expect(onChange).toHaveBeenCalledWith(20); }); it('decreases the value when key "left" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); - - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.LEFT }); - - expect(wrapper.state('value')).toBe(20); + const onChange = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.LEFT, + }); + expect(onChange).toHaveBeenCalledWith(20); }); it('sets the value to minimum when key "home" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); - - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.HOME }); - - expect(wrapper.state('value')).toBe(20); + const onChange = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.HOME, + }); + expect(onChange).toHaveBeenCalledWith(20); }); it('sets the value to maximum when the key "end" is pressed', () => { - const wrapper = mount(); - const handler = wrapper.find('.rc-slider-handle').at(1); - - wrapper.simulate('focus'); - handler.simulate('keyDown', { keyCode: keyCode.END }); - - expect(wrapper.state('value')).toBe(100); + const onChange = jest.fn(); + const { container } = render( + , + ); + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: keyCode.END, + }); + expect(onChange).toHaveBeenCalledWith(100); }); }); + it('keyboard mix with step & marks', () => { + const onChange = jest.fn(); + + // [0], 3, 7, 10 + const { container } = render( + , + ); + const handler = container.getElementsByClassName('rc-slider-handle')[0]; + + // 0, [3], 7, 10 + fireEvent.keyDown(handler, { keyCode: keyCode.UP }); + expect(onChange).toHaveBeenCalledWith(3); + + // 0, 3, [7], 10 + onChange.mockReset(); + fireEvent.keyDown(handler, { keyCode: keyCode.UP }); + expect(onChange).toHaveBeenCalledWith(7); + + // 0, 3, 7, [10] + onChange.mockReset(); + fireEvent.keyDown(handler, { keyCode: keyCode.UP }); + expect(onChange).toHaveBeenCalledWith(10); + + // 0, 3, 7, [10] + onChange.mockReset(); + fireEvent.keyDown(handler, { keyCode: keyCode.UP }); + expect(onChange).not.toHaveBeenCalled(); + + // 0, 3, [7], 10 + onChange.mockReset(); + fireEvent.keyDown(handler, { keyCode: keyCode.DOWN }); + expect(onChange).toHaveBeenCalledWith(7); + + // 0, [3], 7, 10 + onChange.mockReset(); + fireEvent.keyDown(handler, { keyCode: keyCode.DOWN }); + expect(onChange).toHaveBeenCalledWith(3); + + // [0], 3, 7, 10 + onChange.mockReset(); + fireEvent.keyDown(handler, { keyCode: keyCode.DOWN }); + expect(onChange).toHaveBeenCalledWith(0); + + // [0], 3, 7, 10 + onChange.mockReset(); + fireEvent.keyDown(handler, { keyCode: keyCode.DOWN }); + expect(onChange).not.toHaveBeenCalled(); + }); + it('sets aria-label on the handle', () => { - const wrapper = mount(); - expect(wrapper.find('.rc-slider-handle').at(1).prop('aria-label')).toEqual('Some Label'); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'aria-label', + 'Some Label', + ); }); it('sets aria-labelledby on the handle', () => { - const wrapper = mount(); - expect(wrapper.find('.rc-slider-handle').at(1).prop('aria-labelledby')).toEqual('some_id'); + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'aria-labelledby', + 'some_id', + ); }); it('sets aria-valuetext on the handle', () => { - const wrapper = mount( `${value} of something`} />); - const handle = wrapper.find('.rc-slider-handle').at(1); - - expect(handle.prop('aria-valuetext')).toEqual('3 of something'); - - wrapper.simulate('focus'); - handle.simulate('keyDown', { keyCode: keyCode.RIGHT }); - - expect(wrapper.find('.rc-slider-handle').at(1).props()['aria-valuetext']).toEqual('4 of something'); + const { container } = render( + `${value} of something`} + />, + ); + const handle = container.getElementsByClassName('rc-slider-handle')[0]; + expect(handle).toHaveAttribute('aria-valuetext', '3 of something'); + + fireEvent.keyDown(handle, { keyCode: keyCode.RIGHT }); + expect(handle).toHaveAttribute('aria-valuetext', '4 of something'); }); describe('focus & blur', () => { - let container; - - beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - }); - - afterEach(() => { - document.body.removeChild(container); - }); - - const mockRect = (wrapper) => { - wrapper.instance().sliderRef.getBoundingClientRect = () => ({ - left: 10, - width: 100, - }); - }; - - it('focus()', () => { + it('focus', () => { const handleFocus = jest.fn(); - const wrapper = mount( + const { container, unmount } = render( , - { attachTo: container } ); - mockRect(wrapper); - wrapper.instance().focus(); + container.getElementsByClassName('rc-slider-handle')[0].focus(); expect(handleFocus).toBeCalled(); + + unmount(); }); it('blur', () => { const handleBlur = jest.fn(); - const wrapper = mount( + const { container, unmount } = render( , - { attachTo: container } ); - mockRect(wrapper); - wrapper.instance().focus(); - wrapper.instance().blur(); + container.getElementsByClassName('rc-slider-handle')[0].focus(); + container.getElementsByClassName('rc-slider-handle')[0].blur(); expect(handleBlur).toBeCalled(); + + unmount(); + }); + + it('ref focus & blur', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const ref = React.createRef(); + render(); + + ref.current.focus(); + expect(onFocus).toBeCalled(); + + ref.current.blur(); + expect(onBlur).toBeCalled(); }); }); it('should not be out of range when value is null', () => { - const wrapper = mount(); - expect(wrapper.find('Track').props().length >= 0).toBeTruthy(); + const { container, rerender } = render(); + expect(container.getElementsByClassName('rc-slider-track')).toHaveLength(0); + + rerender(); + expect(container.getElementsByClassName('rc-slider-track')).toHaveLength(1); + }); + + describe('click slider to change value', () => { + it('ltr', () => { + const onChange = jest.fn(); + const { container } = render(); + fireEvent.mouseDown(container.querySelector('.rc-slider'), { + clientX: 20, + }); + + expect(onChange).toHaveBeenCalledWith(20); + }); + + it('rtl', () => { + const onChange = jest.fn(); + const { container } = render(); + fireEvent.mouseDown(container.querySelector('.rc-slider'), { + clientX: 20, + }); + + expect(onChange).toHaveBeenCalledWith(80); + }); + + it('btt', () => { + const onChange = jest.fn(); + const { container } = render(); + fireEvent.mouseDown(container.querySelector('.rc-slider'), { + clientY: 93, + }); + + expect(onChange).toHaveBeenCalledWith(7); + }); + + it('ttb', () => { + const onChange = jest.fn(); + const { container } = render(); + fireEvent.mouseDown(container.querySelector('.rc-slider'), { + clientY: 93, + }); + + expect(onChange).toHaveBeenCalledWith(93); + }); + + it('null value click to become 2 values', () => { + const onChange = jest.fn(); + const { container } = render(); + fireEvent.mouseDown(container.querySelector('.rc-slider'), { + clientX: 20, + }); + + expect(onChange).toHaveBeenCalledWith([20, 20]); + }); + }); + + it('autoFocus', () => { + const onFocus = jest.fn(); + render(); + + expect(onFocus).toHaveBeenCalled(); + }); + + it('custom handle', () => { + const { container } = render( + + React.cloneElement(node, { + className: classNames(node.props.className, 'custom-handle'), + }) + } + />, + ); + + expect(container.querySelector('.custom-handle')).toBeTruthy(); }); }); diff --git a/tests/__snapshots__/Range.test.js.snap b/tests/__snapshots__/Range.test.js.snap index 077a380ea..32f6c41e3 100644 --- a/tests/__snapshots__/Range.test.js.snap +++ b/tests/__snapshots__/Range.test.js.snap @@ -2,22 +2,22 @@ exports[`Range should render Multi-Range with correct DOM structure 1`] = `
-
`; exports[`Range should render Range with correct DOM structure 1`] = `
-
`; diff --git a/tests/__snapshots__/Slider.test.js.snap b/tests/__snapshots__/Slider.test.js.snap index cefa662d9..c1e40298d 100644 --- a/tests/__snapshots__/Slider.test.js.snap +++ b/tests/__snapshots__/Slider.test.js.snap @@ -2,14 +2,14 @@ exports[`Slider should render Slider with correct DOM structure 1`] = `
-
`; exports[`Slider should render Slider without handle if value is null 1`] = `
-
-
`; diff --git a/tests/common.test.js b/tests/common.test.js new file mode 100644 index 000000000..b27277d81 --- /dev/null +++ b/tests/common.test.js @@ -0,0 +1,317 @@ +/* eslint-disable max-len, no-undef */ +import React from 'react'; +import { render, fireEvent, createEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; +import KeyCode from 'rc-util/lib/KeyCode'; +import Slider, { Range, createSliderWithTooltip } from '../src'; + +// const setWidth = (object, width) => { +// // https://github.com/tmpvar/jsdom/commit/0cdb2efcc69b6672dc2928644fc0172df5521176 +// Object.defineProperty(object, 'getBoundingClientRect', { +// value: () => ({ +// width, +// // Let all other values retain the JSDom default of `0`. +// bottom: 0, +// height: 0, +// left: 0, +// right: 0, +// top: 0, +// }), +// enumerable: true, +// configurable: true, +// }); +// }; + +describe('Common', () => { + beforeAll(() => { + spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ + width: 100, + height: 100, + }), + }); + }); + + it('should render vertical Slider/Range, when `vertical` is true', () => { + const { container: container1 } = render(); + expect(container1.getElementsByClassName('rc-slider-vertical')).toHaveLength(1); + + const { container: container2 } = render(); + expect(container2.getElementsByClassName('rc-slider-vertical')).toHaveLength(1); + }); + + it('should render dots correctly when `dots=true`', () => { + const { container: container1 } = render(); + expect(container1.getElementsByClassName('rc-slider-dot')).toHaveLength(11); + expect(container1.getElementsByClassName('rc-slider-dot-active')).toHaveLength(6); + + const { container: container2 } = render(); + expect(container2.getElementsByClassName('rc-slider-dot')).toHaveLength(11); + expect(container2.getElementsByClassName('rc-slider-dot-active')).toHaveLength(4); + }); + + it('should not set value greater than `max` or smaller `min`', () => { + const { container: container1 } = render(); + expect( + container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('10'); + + const { container: container2 } = render(); + expect( + container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('90'); + + const { container: container3 } = render(); + expect( + container3.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('10'); + expect( + container3.getElementsByClassName('rc-slider-handle')[1].getAttribute('aria-valuenow'), + ).toBe('90'); + }); + + it('should not set values when sending invalid numbers', () => { + const { container: container1 } = render(); + expect( + container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('0'); + + const { container: container2 } = render(); + expect( + container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('100'); + + const { container: container3 } = render( + , + ); + expect( + container3.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('0'); + expect( + container3.getElementsByClassName('rc-slider-handle')[1].getAttribute('aria-valuenow'), + ).toBe('100'); + }); + + it('should update value when it is out of range', () => { + const sliderOnChange = jest.fn(); + const { container: container1, rerender: rerender1 } = render( + , + ); + rerender1(); + expect( + container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('10'); + + const rangeOnChange = jest.fn(); + const { container: container2, rerender: rerender2 } = render( + , + ); + rerender2(); + expect( + container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('10'); + }); + + it('should not trigger onChange when no min and max', () => { + const sliderOnChange = jest.fn(); + const { container: container1, rerender: rerender1 } = render( + , + ); + rerender1(); + expect( + container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('100'); + expect(sliderOnChange).not.toHaveBeenCalled(); + + const rangeOnChange = jest.fn(); + const { container: container2, rerender: rerender2 } = render( + , + ); + rerender2(); + expect( + container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('0'); + expect( + container2.getElementsByClassName('rc-slider-handle')[1].getAttribute('aria-valuenow'), + ).toBe('100'); + expect(rangeOnChange).not.toHaveBeenCalled(); + }); + + it('should not trigger onChange when value is out of range', () => { + const sliderOnChange = jest.fn(); + const { container: container1, rerender: rerender1 } = render( + , + ); + rerender1(); + expect( + container1.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('10'); + expect(sliderOnChange).not.toHaveBeenCalled(); + + const rangeOnChange = jest.fn(); + const { container: container2, rerender: rerender2 } = render( + , + ); + rerender2(); + expect( + container2.getElementsByClassName('rc-slider-handle')[0].getAttribute('aria-valuenow'), + ).toBe('0'); + expect( + container2.getElementsByClassName('rc-slider-handle')[1].getAttribute('aria-valuenow'), + ).toBe('10'); + expect(rangeOnChange).not.toHaveBeenCalled(); + }); + + it('should not call onChange when value is the same', () => { + const handler = jest.fn(); + + const { container: container1 } = render(); + const handle1 = container1.getElementsByClassName('rc-slider-handle')[0]; + fireEvent.mouseDown(handle1); + fireEvent.mouseMove(handle1); + fireEvent.mouseUp(handle1); + + const { container: container2 } = render(); + const handle2 = container2.getElementsByClassName('rc-slider-handle')[1]; + fireEvent.mouseDown(handle2); + fireEvent.mouseMove(handle2); + fireEvent.mouseUp(handle2); + + expect(handler).not.toHaveBeenCalled(); + }); + + // TODO: should update the following test cases for it should test API instead implementation + // it('should set `dragOffset` to correct value when the left handle is clicked off-center', () => { + // const { container } = render(); + // setWidth(wrapper.instance().sliderRef, 100); + // const leftHandle = wrapper + // .find('.rc-slider-handle') + // .at(1) + // .instance(); + // wrapper.simulate('mousedown', { + // type: 'mousedown', + // target: leftHandle, + // pageX: 5, + // button: 0, + // stopPropagation() {}, + // preventDefault() {}, + // }); + // expect(wrapper.instance().dragOffset).toBe(5); + // }); + + // it('should respect `dragOffset` while dragging the handle via MouseEvents', () => { + // const { container } = render(); + // setWidth(wrapper.instance().sliderRef, 100); + // const leftHandle = wrapper + // .find('.rc-slider-handle') + // .at(1) + // .instance(); + // wrapper.simulate('mousedown', { + // type: 'mousedown', + // target: leftHandle, + // pageX: 5, + // button: 0, + // stopPropagation() {}, + // preventDefault() {}, + // }); + // expect(wrapper.instance().dragOffset).toBe(5); + // wrapper.instance().onMouseMove({ + // // to propagation + // type: 'mousemove', + // target: leftHandle, + // pageX: 14, + // button: 0, + // stopPropagation() {}, + // preventDefault() {}, + // }); + // expect(wrapper.instance().getValue()).toBe(9); + // }); + + it('should not go to right direction when mouse go to the left', () => { + const { container } = render(); + const leftHandle = container.getElementsByClassName('rc-slider-handle')[0]; + + const mouseDown = createEvent.mouseDown(leftHandle); + mouseDown.pageX = 5; + + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'aria-valuenow', + '0', + ); + + const mouseMove = createEvent.mouseMove(leftHandle); + mouseMove.pageX = 0; + + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'aria-valuenow', + '0', + ); + }); + + it('should call onAfterChange when clicked on mark label', () => { + const labelId = 'to-be-clicked'; + const marks = { + 0: 'some other label', + 100: some label, + }; + + const sliderOnChange = jest.fn(); + const sliderOnAfterChange = jest.fn(); + const { container } = render( + , + ); + const sliderHandleWrapper = container.querySelector(`#${labelId}`); + fireEvent.mouseDown(sliderHandleWrapper); + fireEvent.mouseUp(sliderHandleWrapper); + fireEvent.click(sliderHandleWrapper); + expect(sliderOnChange).toHaveBeenCalled(); + expect(sliderOnAfterChange).toHaveBeenCalled(); + + const rangeOnAfterChange = jest.fn(); + const { container: container2 } = render( + , + ); + const rangeHandleWrapper = container2.querySelector(`#${labelId}`); + fireEvent.click(rangeHandleWrapper); + expect(rangeOnAfterChange).toHaveBeenCalled(); + }); + + it('only call onAfterChange once', () => { + const sliderOnChange = jest.fn(); + const sliderOnAfterChange = jest.fn(); + const { container } = render( + , + ); + + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { + keyCode: KeyCode.UP, + }); + + expect(sliderOnChange).toHaveBeenCalled(); + expect(sliderOnAfterChange).toHaveBeenCalled(); + expect(sliderOnAfterChange).toHaveBeenCalledTimes(1); + }); + + // Move to antd instead + // it('the tooltip should be attach to the container with the id tooltip', () => { + // const SliderWithTooltip = createSliderWithTooltip(Slider); + // const tooltipPrefixer = { + // prefixCls: 'slider-tooltip', + // }; + // const tooltipParent = document.createElement('div'); + // tooltipParent.setAttribute('id', 'tooltip'); + // const { container } = render( + // document.getElementById('tooltip')} + // />, + // ); + // expect(wrapper.instance().props.getTooltipContainer).toBeTruthy(); + // }); +}); diff --git a/tests/common/SliderTooltip.test.js b/tests/common/SliderTooltip.test.js deleted file mode 100644 index 11c76a2d4..000000000 --- a/tests/common/SliderTooltip.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from 'react'; -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; -import SliderTooltip from '../../src/common/SliderTooltip'; - -describe('SliderTooltip', () => { - it('should keepAlign by calling forcePopupAlign', async () => { - let ref; - mount( - { - ref = node; - }} - > - aaaa - , - ); - ref.forcePopupAlign = jest.fn(); - await act(async () => { - await new Promise(res => setTimeout(res, 200)); - }); - expect(ref.forcePopupAlign).toHaveBeenCalled(); - }); - - it('should not crash when unmount', async () => { - const wrapper = mount( - - aaaa - , - ); - wrapper.unmount(); - }); -}); diff --git a/tests/common/createSlider.test.js b/tests/common/createSlider.test.js deleted file mode 100644 index d1e492a0c..000000000 --- a/tests/common/createSlider.test.js +++ /dev/null @@ -1,342 +0,0 @@ -/* eslint-disable max-len, no-undef */ -import React from 'react'; -import { mount } from 'enzyme'; -import Slider, { Range, createSliderWithTooltip } from '../../src'; - -const setWidth = (object, width) => { - // https://github.com/tmpvar/jsdom/commit/0cdb2efcc69b6672dc2928644fc0172df5521176 - Object.defineProperty(object, 'getBoundingClientRect', { - value: () => ({ - width, - // Let all other values retain the JSDom default of `0`. - bottom: 0, - height: 0, - left: 0, - right: 0, - top: 0, - }), - enumerable: true, - configurable: true, - }); -}; - -describe('createSlider', () => { - it('should render vertical Slider/Range, when `vertical` is true', () => { - const sliderWrapper = mount(); - expect(sliderWrapper.find('.rc-slider-vertical').length).toBe(1); - - const rangeWrapper = mount(); - expect(rangeWrapper.find('.rc-slider-vertical').length).toBe(1); - }); - - it('should render dots correctly when `dots=true`', () => { - const sliderWrapper = mount(); - expect(sliderWrapper.find('.rc-slider-dot').length).toBe(11); - expect(sliderWrapper.find('.rc-slider-dot-active').length).toBe(6); - - const rangeWrapper = mount(); - expect(rangeWrapper.find('.rc-slider-dot').length).toBe(11); - expect(rangeWrapper.find('.rc-slider-dot-active').length).toBe(4); - }); - - it('should not set value greater than `max` or smaller `min`', () => { - const sliderWithMinWrapper = mount(); - expect(sliderWithMinWrapper.state('value')).toBe(10); - - const sliderWithMaxWrapper = mount(); - expect(sliderWithMaxWrapper.state('value')).toBe(90); - - const rangeWrapper = mount(); - expect(rangeWrapper.state('bounds')[0]).toBe(10); - expect(rangeWrapper.state('bounds')[1]).toBe(90); - }); - - it('should not set values when sending invalid numbers', () => { - const sliderWithMinWrapper = mount(); - expect(sliderWithMinWrapper.state('value')).toBe(0); - - const sliderWithMaxWrapper = mount(); - expect(sliderWithMaxWrapper.state('value')).toBe(0); - - const rangeWrapper = mount(); - expect(rangeWrapper.state('bounds')[0]).toBe(0); - expect(rangeWrapper.state('bounds')[1]).toBe(0); - }); - - it('should update value when it is out of range', () => { - const sliderOnChange = jest.fn(); - const sliderWrapper = mount(); - sliderWrapper.setProps({ min: 10 }); - expect(sliderWrapper.state('value')).toBe(10); - expect(sliderOnChange).toHaveBeenLastCalledWith(10); - - const rangeOnChange = jest.fn(); - const rangeWrapper = mount(); - rangeWrapper.setProps({ min: 10 }); - expect(rangeWrapper.state('bounds')).toEqual([10, 10]); - expect(rangeOnChange).toHaveBeenLastCalledWith([10, 10]); - }); - - it('should not trigger onChange when no min and max', () => { - const sliderOnChange = jest.fn(); - const sliderWrapper = mount(); - sliderWrapper.setProps({ value: 100 }); - expect(sliderOnChange).not.toHaveBeenCalled(); - - const rangeOnChange = jest.fn(); - const rangeWrapper = mount(); - rangeWrapper.setProps({ value: [0, 100] }); - expect(rangeOnChange).not.toHaveBeenCalled(); - }); - - it('should not trigger onChange when value is out of range', () => { - const sliderOnChange = jest.fn(); - const sliderWrapper = mount(); - sliderWrapper.setProps({ value: 11 }); - expect(sliderWrapper.state('value')).toBe(10); - expect(sliderOnChange).not.toHaveBeenCalled(); - - const rangeOnChange = jest.fn(); - const rangeWrapper = mount(); - rangeWrapper.setProps({ value: [0, 100] }); - expect(rangeWrapper.state('bounds')).toEqual([0, 10]); - expect(rangeOnChange).not.toHaveBeenCalled(); - }); - - it('should not call onChange when value is the same', () => { - const handler = jest.fn(); - - const sliderWrapper = mount(); - const sliderHandleWrapper = sliderWrapper.find('.rc-slider-handle').at(1); - sliderHandleWrapper.simulate('mousedown'); - sliderHandleWrapper.simulate('mousemove'); - sliderHandleWrapper.simulate('mouseup'); - - const rangeWrapper = mount(); - const rangeHandleWrapper = rangeWrapper.find('.rc-slider-handle-1').at(1); - rangeHandleWrapper.simulate('mousedown'); - rangeHandleWrapper.simulate('mousemove'); - rangeHandleWrapper.simulate('mouseup'); - - expect(handler).not.toHaveBeenCalled(); - }); - - it('Should remove event listeners if unmounted during drag', () => { - const wrapper = mount(); - - setWidth(wrapper.instance().sliderRef, 100); - const sliderTrack = wrapper.find('.rc-slider-track').get(0); - wrapper.simulate('touchstart', { - type: 'touchstart', - target: sliderTrack, - touches: [{ pageX: 5 }], - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().onTouchUpListener).toBeTruthy(); - wrapper.instance().onTouchUpListener.remove = jest.fn(); - wrapper.unmount(); - // expect(wrapper.instance().onTouchUpListener.remove).toHaveBeenCalled(); - }); - - // TODO: should update the following test cases for it should test API instead implementation - it('should set `dragOffset` to correct value when the left handle is clicked off-center', () => { - const wrapper = mount(); - setWidth(wrapper.instance().sliderRef, 100); - const leftHandle = wrapper - .find('.rc-slider-handle') - .at(1) - .instance(); - wrapper.simulate('mousedown', { - type: 'mousedown', - target: leftHandle, - pageX: 5, - button: 0, - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().dragOffset).toBe(5); - }); - - it('should respect `dragOffset` while dragging the handle via MouseEvents', () => { - const wrapper = mount(); - setWidth(wrapper.instance().sliderRef, 100); - const leftHandle = wrapper - .find('.rc-slider-handle') - .at(1) - .instance(); - wrapper.simulate('mousedown', { - type: 'mousedown', - target: leftHandle, - pageX: 5, - button: 0, - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().dragOffset).toBe(5); - wrapper.instance().onMouseMove({ - // to propagation - type: 'mousemove', - target: leftHandle, - pageX: 14, - button: 0, - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().getValue()).toBe(9); - }); - - it('should not go to right direction when mouse go to the left', () => { - const wrapper = mount(); - setWidth(wrapper.instance().sliderRef, 100); - const leftHandle = wrapper - .find('.rc-slider-handle') - .at(1) - .instance(); - wrapper.simulate('mousedown', { - type: 'mousedown', - target: leftHandle, - pageX: 5, - button: 0, - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().getValue()).toBe(0); // zero on start - wrapper.instance().onMouseMove({ - // to propagation - type: 'mousemove', - target: leftHandle, - pageX: 0, - button: 0, - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().getValue()).toBe(0); // still zero - }); - - it("should set `dragOffset` to 0 when the MouseEvent target isn't a handle", () => { - const wrapper = mount(); - setWidth(wrapper.instance().sliderRef, 100); - const sliderTrack = wrapper.find('.rc-slider-track').get(0); - wrapper.simulate('mousedown', { - type: 'mousedown', - target: sliderTrack, - pageX: 5, - button: 0, - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().dragOffset).toBe(0); - }); - - it('should set `dragOffset` to correct value when the left handle is touched off-center', () => { - const wrapper = mount(); - setWidth(wrapper.instance().sliderRef, 100); - const leftHandle = wrapper - .find('.rc-slider-handle') - .at(1) - .instance(); - wrapper.simulate('touchstart', { - type: 'touchstart', - target: leftHandle, - touches: [{ pageX: 5 }], - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().dragOffset).toBe(5); - }); - - it('should respect `dragOffset` while dragging the handle via TouchEvents', () => { - const wrapper = mount(); - setWidth(wrapper.instance().sliderRef, 100); - const leftHandle = wrapper - .find('.rc-slider-handle') - .at(1) - .instance(); - wrapper.simulate('touchstart', { - type: 'touchstart', - target: leftHandle, - touches: [{ pageX: 5 }], - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().dragOffset).toBe(5); - wrapper.instance().onTouchMove({ - // to propagation - type: 'touchmove', - target: leftHandle, - touches: [{ pageX: 14 }], - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().getValue()).toBe(9); - }); - - it("should set `dragOffset` to 0 when the TouchEvent target isn't a handle", () => { - const wrapper = mount(); - setWidth(wrapper.instance().sliderRef, 100); - const sliderTrack = wrapper.find('.rc-slider-track').get(0); - wrapper.simulate('touchstart', { - type: 'touchstart', - target: sliderTrack, - touches: [{ pageX: 5 }], - stopPropagation() {}, - preventDefault() {}, - }); - expect(wrapper.instance().dragOffset).toBe(0); - }); - - it('should call onAfterChange when clicked on mark label', () => { - const labelId = 'to-be-clicked'; - const marks = { - 0: 'some other label', - 100: some label, - }; - - const sliderOnAfterChange = jest.fn(); - const sliderWrapper = mount( - , - ); - const sliderHandleWrapper = sliderWrapper.find(`#${labelId}`).at(0); - sliderHandleWrapper.simulate('mousedown'); - sliderHandleWrapper.simulate('mouseup'); - expect(sliderOnAfterChange).toHaveBeenCalled(); - - const rangeOnAfterChange = jest.fn(); - const rangeWrapper = mount( - , - ); - const rangeHandleWrapper = rangeWrapper.find(`#${labelId}`).at(0); - rangeHandleWrapper.simulate('mousedown'); - rangeHandleWrapper.simulate('mouseup'); - - expect(rangeOnAfterChange).toHaveBeenCalled(); - }); - - it('only call onAfterChange once', () => { - const sliderOnAfterChange = jest.fn(); - const sliderWrapper = mount(); - - sliderWrapper.instance().onStart(); - sliderWrapper.instance().onEnd(); - sliderWrapper.instance().onEnd(); - expect(sliderOnAfterChange).toHaveBeenCalled(); - expect(sliderOnAfterChange).toHaveBeenCalledTimes(1); - }); - - it('the tooltip should be attach to the container with the id tooltip', () => { - const SliderWithTooltip = createSliderWithTooltip(Slider); - const tooltipPrefixer = { - prefixCls: 'slider-tooltip', - }; - const tooltipParent = document.createElement('div'); - tooltipParent.setAttribute('id', 'tooltip'); - const wrapper = mount( - document.getElementById('tooltip')} - />, - ); - expect(wrapper.instance().props.getTooltipContainer).toBeTruthy(); - }); -}); diff --git a/tests/common/marks.test.js b/tests/common/marks.test.js deleted file mode 100644 index 6584814ed..000000000 --- a/tests/common/marks.test.js +++ /dev/null @@ -1,68 +0,0 @@ -/* eslint-disable max-len, no-undef */ -import React from 'react'; -import { mount } from 'enzyme'; -import Slider, { Range } from '../../src'; - -describe('marks', () => { - let originGetBoundingClientRect; - beforeAll(() => { - // Mock - originGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; - HTMLElement.prototype.getBoundingClientRect = () => ({ - width: 100, - height: 100, - }); - }); - - afterAll(() => { - // Restore Mock - HTMLElement.prototype.getBoundingClientRect = originGetBoundingClientRect; - }); - - it('should render marks correctly when `marks` is not an empty object', () => { - const marks = { 0: 0, 30: '30', 99: '', 100: '100' }; - - const sliderWrapper = mount(); - expect(sliderWrapper.find('.rc-slider-mark-text').length).toBe(3); - expect(sliderWrapper.find('.rc-slider-mark-text').at(0).instance().innerHTML).toBe('0'); - expect(sliderWrapper.find('.rc-slider-mark-text').at(1).instance().innerHTML).toBe('30'); - expect(sliderWrapper.find('.rc-slider-mark-text').at(2).instance().innerHTML).toBe('100'); - - const rangeWrapper = mount(); - expect(rangeWrapper.find('.rc-slider-mark-text').length).toBe(3); - expect(rangeWrapper.find('.rc-slider-mark-text').at(0).instance().innerHTML).toBe('0'); - expect(rangeWrapper.find('.rc-slider-mark-text').at(1).instance().innerHTML).toBe('30'); - expect(rangeWrapper.find('.rc-slider-mark-text').at(2).instance().innerHTML).toBe('100'); - }); - - it('should select correct value while click on marks', () => { - const marks = { 0: '0', 30: '30', 100: '100' }; - - const sliderWrapper = mount(); - const sliderMark = sliderWrapper.find('.rc-slider-mark-text').at(1); - sliderMark.simulate('mousedown', { - type: 'mousedown', - target: sliderMark, - pageX: 25, - button: 0, - stopPropagation() {}, - preventDefault() {}, - }); - expect(sliderWrapper.state('value')).toBe(30); - }); - - // TODO: not implement yet - xit('should select correct value while click on marks in Ranger', () => { - const rangeWrapper = mount(); - const rangeMark = rangeWrapper.find('.rc-slider-mark-text').at(1); - rangeMark.simulate('mousedown', { - type: 'mousedown', - target: rangeMark, - pageX: 25, - button: 0, - stopPropagation() {}, - preventDefault() {}, - }); - expect(rangeWrapper.state('bounds')).toBe([0, 30]); - }); -}); diff --git a/tests/marks.test.js b/tests/marks.test.js new file mode 100644 index 000000000..46d72c26a --- /dev/null +++ b/tests/marks.test.js @@ -0,0 +1,60 @@ +/* eslint-disable max-len, no-undef */ +import React from 'react'; +import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Slider from '../src'; + +describe('marks', () => { + beforeAll(() => { + spyElementPrototypes(HTMLElement, { + getBoundingClientRect: () => ({ + width: 100, + height: 100, + }), + }); + }); + + it('should render marks correctly when `marks` is not an empty object', () => { + const marks = { 0: 0, 30: '30', 99: '', 100: '100' }; + + const { container } = render(); + expect(container.getElementsByClassName('rc-slider-mark-text')).toHaveLength(3); + expect(container.getElementsByClassName('rc-slider-mark-text')[0].innerHTML).toBe('0'); + expect(container.getElementsByClassName('rc-slider-mark-text')[1].innerHTML).toBe('30'); + expect(container.getElementsByClassName('rc-slider-mark-text')[2].innerHTML).toBe('100'); + + const { container: container2 } = render(); + expect(container2.getElementsByClassName('rc-slider-mark-text')).toHaveLength(3); + expect(container2.getElementsByClassName('rc-slider-mark-text')[0].innerHTML).toBe('0'); + expect(container2.getElementsByClassName('rc-slider-mark-text')[1].innerHTML).toBe('30'); + expect(container2.getElementsByClassName('rc-slider-mark-text')[2].innerHTML).toBe('100'); + }); + + it('should select correct value while click on marks', () => { + const marks = { 0: '0', 30: '30', 100: '100' }; + + const { container } = render(); + fireEvent.click(container.getElementsByClassName('rc-slider-mark-text')[1]); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute( + 'aria-valuenow', + '30', + ); + }); + + // TODO: not implement yet + // zombieJ: since this test leave years but not implement. Could we remove this? + // xit('should select correct value while click on marks in Ranger', () => { + // const rangeWrapper = render(); + // const rangeMark = rangeWrapper.find('.rc-slider-mark-text').at(1); + // rangeMark.simulate('mousedown', { + // type: 'mousedown', + // target: rangeMark, + // pageX: 25, + // button: 0, + // stopPropagation() {}, + // preventDefault() {}, + // }); + // expect(rangeWrapper.state('bounds')).toBe([0, 30]); + // }); +}); diff --git a/tests/setup.js b/tests/setup.js index 4ef4aadff..b74e53e15 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,6 +1,7 @@ global.requestAnimationFrame = global.requestAnimationFrame || function _raf(cb) { return setTimeout(cb, 0); }; +require('regenerator-runtime'); const Enzyme = require('enzyme'); const Adapter = require('enzyme-adapter-react-16'); diff --git a/tests/type.test.tsx b/tests/type.test.tsx deleted file mode 100644 index e94b2f71c..000000000 --- a/tests/type.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import React from 'react'; -import Slider, { createSliderWithTooltip, Range, Handle } from '../src'; -import type { SliderProps } from '@/Slider'; -import type { RangeProps } from '@/Range'; -import type { HandleProps } from '@/Handle'; -import type { ComponentWrapperProps } from '@/createSliderWithTooltip'; - -describe('Slider.Typescript', () => { - const sliderProps: SliderProps = { - value: 1, - defaultValue: 1, - min: 0, - max: 2, - step: 0.5, - prefixCls: 'rc-slider', - onChange: (val: number) => val, - onBeforeChange: (val: number) => val, - onAfterChange: (val: number) => val, - vertical: true, - included: true, - disabled: false, - reverse: false, - minimumTrackStyle: {}, - trackStyle: { - borderRadius: '2px', - }, - handleStyle: {}, - tabIndex: 1, - ariaLabelForHandle: 'ariaLabelForHandle', - ariaLabelledByForHandle: 'ariaLabelledByForHandle', - ariaValueTextFormatterForHandle: (i: number) => `ariaValueTextFormatterForHandle${i}`, - startPoint: 1, - handle(props) { - return ; - }, - className: 'class', - marks: { 0: 'mark1', 1: { style: { color: '#fff' }, label: 'label' } }, - dots: true, - maximumTrackStyle: { color: '#fff' }, - style: { color: '#fff' }, - railStyle: { color: '#fff' }, - dotStyle: { color: '#fff' }, - activeDotStyle: { color: '#fff' }, - }; - - const rangeProps: RangeProps = { - value: [1, 2], - defaultValue: [1, 2], - count: 1, - min: 0, - max: 2, - allowCross: false, - pushable: true, - onChange: (val: number[]) => val, - onBeforeChange: (val: number[]) => val, - onAfterChange: (val: number[]) => val, - reverse: false, - vertical: true, - marks: { - 0: { - label: '0%', - style: { - color: '#fff', - }, - }, - 0.5: , - }, - step: 0.5, - threshold: 100, - prefixCls: 'rc-slider', - included: true, - disabled: false, - trackStyle: [ - { - borderRadius: '2px', - }, - ], - handleStyle: [{}], - ariaLabelGroupForHandles: 'ariaLabelGroupForHandles', - ariaLabelledByGroupForHandles: ['ariaLabelledByGroupForHandles'], - ariaValueTextFormatterGroupForHandles: [(i: number) => `ariaValueTextFormatterGroupForHandles${i}`], - handle(props) { - return ; - }, - className: 'class', - dots: true, - maximumTrackStyle: { color: '#fff' }, - style: { color: '#fff' }, - railStyle: { color: '#fff' }, - dotStyle: { color: '#fff' }, - activeDotStyle: { color: '#fff' }, - }; - - const handleProps: HandleProps = { - prefixCls: 'rc-slider', - className: 'rc-slider-handle', - vertical: true, - reverse: false, - offset: 2, - style: { - width: '2px', - }, - disabled: false, - min: 0, - max: 2, - value: 1, - tabIndex: 0, - ariaLabel: 'ariaLabel', - ariaLabelledBy: 'ariaLabelledBy', - ariaValueTextFormatter: (val: number) => String(val), - onMouseEnter: e => e, - onMouseLeave: e => e, - }; - - const withTooltipProps: ComponentWrapperProps = { - tipFormatter: (val: number) => `tip: ${val}`, - tipProps: { - prefixCls: 'rc-slider-tooltip', - overlay: 'overlay', - placement: 'top', - visible: true, - }, - getTooltipContainer: () => document.body, - }; - - it('Slider', () => { - const slider = ; - expect(slider).toBeTruthy(); - }); - - it('Range', () => { - const range = ; - expect(range).toBeTruthy(); - }); - - it('Handle', () => { - const handle = ; - expect(handle).toBeTruthy(); - }); - - it('createSliderWithTooltip', () => { - const TooltipSlider = createSliderWithTooltip(Slider); - const TooltipRangle = createSliderWithTooltip(Range); - const TooltipHandle = createSliderWithTooltip(Handle); - const slider = ; - const range = ; - const handle = ; - expect(slider).toBeTruthy(); - expect(range).toBeTruthy(); - expect(handle).toBeTruthy(); - }); -}); diff --git a/tests/utils.test.js b/tests/utils.test.js deleted file mode 100644 index 26a4a055a..000000000 --- a/tests/utils.test.js +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable max-len, no-undef */ -import * as utils from '../src/utils'; - -describe('utils', () => { - describe('getClosestPoint', () => { - it('should return closest value', () => { - const value = 40; - const props = { - marks: { 0: 0, 30: 30, 60: 60 }, - step: null, - min: 0, - max: 100, - }; - - expect(utils.getClosestPoint(value, props)).toBe(30); - }); - - it('should return closest value (taking step into account)', () => { - const value = 40; - const props = { - marks: { 0: 0, 30: 30, 60: 60 }, - step: 3, - min: 0, - max: 100, - }; - - expect(utils.getClosestPoint(value, props)).toBe(39); - }); - - it('should return closest value (taking boundaries into account)', () => { - const value = 102; - const props = { - marks: {}, - step: 6, - min: 0, - max: 100, - }; - - expect(utils.getClosestPoint(value, props)).toBe(96); - }); - - it('should return closest precision float value', () => { - expect( - utils.ensureValuePrecision(8151.23, { - marks: {}, - step: 0.01, - min: 0.2, - max: 8151.23, - }), - ).toBe(8151.23); - - expect( - utils.ensureValuePrecision(0.2, { - marks: {}, - step: 0.01, - min: 0.2, - max: 8151.23, - }), - ).toBe(0.2); - - expect( - utils.ensureValuePrecision(815.23, { - marks: {}, - step: 0.01, - min: 0.2, - max: 8151.23, - }), - ).toBe(815.23); - }); - }); -});