diff --git a/README.md b/README.md index 76577985f..6beb5a60f 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ The following APIs are shared by Slider and Range. | handle | (props) => React.ReactNode | | A handle generator which could be used to customized handle. | | included | boolean | `true` | If the value is `true`, it means a continuous value interval, otherwise, it is a independent value. | | reverse | boolean | `false` | If the value is `true`, it means the component is rendered reverse. | -| disabled | boolean | `false` | If `true`, handles can't be moved. | +| disabled | boolean \| boolean[] | `false` | If `true`, handles can't be moved. This prop can also be an array to disable specific handles in range mode, e.g. `[true, false, true]` disables first and third handles. When any rendered handle is disabled, `editable` mode will be disabled. | | keyboard | boolean | `true` | Support using keyboard to move handlers. | | dots | boolean | `false` | When the `step` value is greater than 1, you can set the `dots` to `true` if you want to render the slider with dots. | | onBeforeChange | Function | NOOP | `onBeforeChange` will be triggered when `ontouchstart` or `onmousedown` is triggered. | diff --git a/assets/index.less b/assets/index.less index 64daded8e..e85c9ab8b 100644 --- a/assets/index.less +++ b/assets/index.less @@ -105,6 +105,20 @@ cursor: -webkit-grabbing; cursor: grabbing; } + + &-disabled { + background-color: #fff; + border-color: @disabledColor; + box-shadow: none; + cursor: not-allowed; + + &:hover, + &:active { + border-color: @disabledColor; + box-shadow: none; + cursor: not-allowed; + } + } } &-mark { diff --git a/docs/demo/disabled-handle.md b/docs/demo/disabled-handle.md new file mode 100644 index 000000000..10c4d9c1f --- /dev/null +++ b/docs/demo/disabled-handle.md @@ -0,0 +1,9 @@ +--- +title: Disabled Handle +title.zh-CN: 禁用特定滑块 +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/disabled-handle.tsx b/docs/examples/disabled-handle.tsx new file mode 100644 index 000000000..9967ad1ea --- /dev/null +++ b/docs/examples/disabled-handle.tsx @@ -0,0 +1,170 @@ +/* eslint react/no-multi-comp: 0, no-console: 0 */ +import Slider from '@rc-component/slider'; +import React, { useState } from 'react'; +import '../../assets/index.less'; + +const style: React.CSSProperties = { + width: 400, + margin: 50, +}; + +const defaultValue = [0, 30, 60, 100]; +const BasicDisabledHandle = () => { + const [disabled, setDisabled] = useState([true, false, false, false]); + + return ( +
+ + Slider disabled {JSON.stringify(disabled)} +
+ {defaultValue.map((_, index) => ( + + ))} +
+
+ ); +}; + +const DisabledHandleAsBoundary = () => { + const [value, setValue] = useState([10, 50, 90]); + + return ( +
+ Array.isArray(v) && setValue(v)} + disabled={[false, true, false]} + /> +

+ Middle handle (50) is disabled and acts as a boundary. First handle cannot go beyond 50, + third handle cannot go below 50. Disabled handle has gray border and not-allowed cursor. +

+
+ ); +}; + +const PushableWithDisabledHandle = () => { + const [value, setValue] = useState([20, 40, 60, 80]); + + return ( +
+ setValue(v as number[])} + disabled={[false, true, false, false]} + pushable={10} + /> +

+ Second handle (40) is disabled. Drag the first handle toward it or push the last two handles + together: enabled handles keep at least 10 apart without crossing the disabled handle. +

+
+ ); +}; + +const SingleSlider = () => { + const [value1, setValue1] = useState(30); + const [value2, setValue2] = useState(30); + + return ( +
+ setValue1(v as number)} disabled /> +
+ setValue2(v as number)} disabled={false} /> +
+ ); +}; + +// Editable mode with disabled handles - editable is disabled when any handle is disabled +const EditableWithDisabled = () => { + const [value, setValue] = useState([0, 30, 100]); + const [disabled, setDisabled] = useState([true, false, false]); + + const hasDisabled = disabled.some((d) => d); + + return ( +
+ setValue(v as number[])} + disabled={disabled} + /> +

+ {hasDisabled + ? 'Editable mode is DISABLED because at least one handle is disabled. Clicking track will move nearest enabled handle.' + : 'Editable mode is ENABLED. Click track to add handles, drag to edge to delete.'} +

+
+ {value.map((val, index) => ( + + ))} +
+

+ Try: Toggle checkboxes to enable/disable handles. When any handle is disabled, you cannot + add or remove handles. When all handles are enabled, editable mode works normally. +

+
+ ); +}; + +export default () => ( +
+
+ single handle disabled + +
+
+

Disabled Handle

+

+ Toggle checkboxes to disable/enable specific handles. Track dragging is disabled when any + handle is disabled. +

+ +
+ +
+

Disabled Handle as Boundary

+ +
+ +
+

Disabled Handle + Pushable

+ +
+ +
+

Editable + Disabled (Editable Disabled When Any Handle Disabled)

+ +
+
+); diff --git a/src/Handles/Handle.tsx b/src/Handles/Handle.tsx index edd7f1149..ae902f4b6 100644 --- a/src/Handles/Handle.tsx +++ b/src/Handles/Handle.tsx @@ -55,7 +55,6 @@ const Handle = React.forwardRef((props, ref) => { min, max, direction, - disabled, keyboard, range, tabIndex, @@ -65,15 +64,20 @@ const Handle = React.forwardRef((props, ref) => { ariaValueTextFormatterForHandle, styles, classNames, + isHandleDisabled, } = React.useContext(SliderContext); + const mergedDisabled = isHandleDisabled(valueIndex); + const handlePrefixCls = `${prefixCls}-handle`; // ============================ Events ============================ const onInternalStartMove = (e: React.MouseEvent | React.TouchEvent) => { - if (!disabled) { - onStartMove(e, valueIndex); + if (mergedDisabled) { + e.stopPropagation(); + return; } + onStartMove(e, valueIndex); }; const onInternalFocus = (e: React.FocusEvent) => { @@ -86,7 +90,7 @@ const Handle = React.forwardRef((props, ref) => { // =========================== Keyboard =========================== const onKeyDown: React.KeyboardEventHandler = (e) => { - if (!disabled && keyboard) { + if (!mergedDisabled && keyboard) { let offset: number | 'min' | 'max' | undefined; // Change the value @@ -161,12 +165,12 @@ const Handle = React.forwardRef((props, ref) => { if (valueIndex !== null) { divProps = { - tabIndex: disabled ? undefined : getIndex(tabIndex, valueIndex) ?? undefined, + tabIndex: mergedDisabled ? undefined : getIndex(tabIndex, valueIndex) ?? undefined, role: 'slider', 'aria-valuemin': min, 'aria-valuemax': max, 'aria-valuenow': value, - 'aria-disabled': disabled, + 'aria-disabled': mergedDisabled, 'aria-label': getIndex(ariaLabelForHandle, valueIndex), 'aria-labelledby': getIndex(ariaLabelledByForHandle, valueIndex), 'aria-required': getIndex(ariaRequired, valueIndex), @@ -190,6 +194,7 @@ const Handle = React.forwardRef((props, ref) => { [`${handlePrefixCls}-${valueIndex + 1}`]: valueIndex !== null && range, [`${handlePrefixCls}-dragging`]: dragging, [`${handlePrefixCls}-dragging-delete`]: draggingDelete, + [`${handlePrefixCls}-disabled`]: mergedDisabled, }, classNames.handle, )} diff --git a/src/Slider.tsx b/src/Slider.tsx index 21b846084..2734924e8 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -12,8 +12,9 @@ import Steps from './Steps'; import Tracks from './Tracks'; import type { SliderContextProps } from './context'; import SliderContext from './context'; +import useDisabled from './hooks/useDisabled'; import useDrag from './hooks/useDrag'; -import useOffset from './hooks/useOffset'; +import useOffset, { getClosestEnabledHandleIndex } from './hooks/useOffset'; import useRange from './hooks/useRange'; import type { AriaValueFormat, @@ -57,7 +58,7 @@ export interface SliderProps { id?: string; // Status - disabled?: boolean; + disabled?: boolean | boolean[]; keyboard?: boolean; autoFocus?: boolean; onFocus?: (e: React.FocusEvent) => void; @@ -131,8 +132,7 @@ const Slider = React.forwardRef>((prop id, - // Status - disabled = false, + disabled: rawDisabled = false, keyboard = true, autoFocus, onFocus, @@ -187,6 +187,7 @@ const Slider = React.forwardRef>((prop const handlesRef = React.useRef(null); const containerRef = React.useRef(null); + const [mergedValue, setValue] = useControlledState(defaultValue, value); const direction = React.useMemo(() => { if (vertical) { @@ -241,6 +242,9 @@ const Slider = React.forwardRef>((prop .sort((a, b) => a.value - b.value); }, [marks]); + // ============================ Disabled ============================ + const [isHandleDisabled, getDisabledState] = useDisabled(rawDisabled); + // ============================ Format ============================ const [formatValue, offsetValues] = useOffset( mergedMin, @@ -249,18 +253,17 @@ const Slider = React.forwardRef>((prop markList, allowCross, mergedPush as false | number, + isHandleDisabled, ); // ============================ Values ============================ - const [mergedValue, setValue] = useControlledState(defaultValue, value); - const rawValues = React.useMemo(() => { const valueList = mergedValue === null || mergedValue === undefined ? [] : Array.isArray(mergedValue) - ? mergedValue - : [mergedValue]; + ? mergedValue + : [mergedValue]; const [val0 = mergedMin] = valueList; let returnValues = mergedValue === null ? [] : [val0]; @@ -290,6 +293,13 @@ const Slider = React.forwardRef>((prop return returnValues; }, [mergedValue, rangeEnabled, mergedMin, count, formatValue]); + const [disabled, hasDisabledHandle] = React.useMemo( + () => getDisabledState(rawValues), + [getDisabledState, rawValues], + ); + + const effectiveRangeEditable = rangeEditable && !hasDisabledHandle; + // =========================== onChange =========================== const getTriggerValue = (triggerValues: number[]) => rangeEnabled ? triggerValues : triggerValues[0]; @@ -323,7 +333,7 @@ const Slider = React.forwardRef>((prop }); const onDelete = (index: number) => { - if (disabled || !rangeEditable || rawValues.length <= minCount) { + if (disabled || !effectiveRangeEditable || rawValues.length <= minCount) { return; } @@ -348,8 +358,9 @@ const Slider = React.forwardRef>((prop triggerChange, finishChange, offsetValues, - rangeEditable, + effectiveRangeEditable, minCount, + isHandleDisabled, ); /** @@ -358,20 +369,30 @@ const Slider = React.forwardRef>((prop */ const changeToCloseValue = (newValue: number, e?: React.MouseEvent) => { if (!disabled) { + const valueIndex = rawValues.length + ? getClosestEnabledHandleIndex( + rawValues, + newValue, + mergedMin, + mergedMax, + mergedPush as false | number, + isHandleDisabled, + ) + : 0; + + if (valueIndex === -1) { + return; + } + // Create new values const cloneNextValues = [...rawValues]; - let valueIndex = 0; let valueBeforeIndex = 0; // Record the index which value < newValue - let valueDist = mergedMax - mergedMin; + const valueDist = rawValues.length + ? Math.abs(newValue - rawValues[valueIndex]) + : mergedMax - mergedMin; rawValues.forEach((val, index) => { - const dist = Math.abs(newValue - val); - if (dist <= valueDist) { - valueDist = dist; - valueIndex = index; - } - if (val < newValue) { valueBeforeIndex = index; } @@ -379,11 +400,12 @@ const Slider = React.forwardRef>((prop let focusIndex = valueIndex; - if (rangeEditable && valueDist !== 0 && (!maxCount || rawValues.length < maxCount)) { + if (effectiveRangeEditable && valueDist !== 0 && (!maxCount || rawValues.length < maxCount)) { cloneNextValues.splice(valueBeforeIndex + 1, 0, newValue); focusIndex = valueBeforeIndex + 1; } else { cloneNextValues[valueIndex] = newValue; + focusIndex = valueIndex; } // Fill value to match default 2 (only when `rawValues` is empty) @@ -442,22 +464,24 @@ const Slider = React.forwardRef>((prop }; // =========================== Keyboard =========================== - const [keyboardValue, setKeyboardValue] = React.useState(null!); + const [keyboardValue, setKeyboardValue] = React.useState<{ value: number; index: number }>(null!); const onHandleOffsetChange = (offset: number | 'min' | 'max', valueIndex: number) => { - if (!disabled) { + if (!disabled && !isHandleDisabled(valueIndex)) { const next = offsetValues(rawValues, offset, valueIndex); onBeforeChange?.(getTriggerValue(rawValues)); triggerChange(next.values); - setKeyboardValue(next.value); + setKeyboardValue({ value: next.value, index: valueIndex }); } }; React.useEffect(() => { if (keyboardValue !== null) { - const valueIndex = rawValues.indexOf(keyboardValue); + const { value: nextKeyboardValue, index } = keyboardValue; + const valueIndex = + rawValues[index] === nextKeyboardValue ? index : rawValues.indexOf(nextKeyboardValue); if (valueIndex >= 0) { handlesRef.current!.focus(valueIndex); } @@ -551,6 +575,7 @@ const Slider = React.forwardRef>((prop ariaValueTextFormatterForHandle, styles: styles || {}, classNames: classNames || {}, + isHandleDisabled, }), [ mergedMin, @@ -570,6 +595,7 @@ const Slider = React.forwardRef>((prop ariaValueTextFormatterForHandle, styles, classNames, + isHandleDisabled, ], ); @@ -625,7 +651,7 @@ const Slider = React.forwardRef>((prop handleRender={handleRender} activeHandleRender={activeHandleRender} onChangeComplete={finishChange} - onDelete={rangeEditable ? onDelete : undefined} + onDelete={effectiveRangeEditable ? onDelete : undefined} /> diff --git a/src/Tracks/index.tsx b/src/Tracks/index.tsx index 1b11e3301..6a9c2ee1f 100644 --- a/src/Tracks/index.tsx +++ b/src/Tracks/index.tsx @@ -14,8 +14,15 @@ export interface TrackProps { } const Tracks: React.FC = (props) => { - const { prefixCls, style, values, startPoint, onStartMove } = props; - const { included, range, min, styles, classNames } = React.useContext(SliderContext); + const { prefixCls, style, values, startPoint, onStartMove: propsOnStartMove } = props; + const { included, range, min, styles, classNames, isHandleDisabled } = React.useContext(SliderContext); + + const hasDisabledHandle = React.useMemo( + () => values.some((_, index) => isHandleDisabled(index)), + [isHandleDisabled, values], + ); + + const onStartMove = hasDisabledHandle ? undefined : propsOnStartMove; // =========================== List =========================== const trackList = React.useMemo(() => { diff --git a/src/context.ts b/src/context.ts index 9a0901c7a..1b13b6fda 100644 --- a/src/context.ts +++ b/src/context.ts @@ -19,6 +19,7 @@ export interface SliderContextProps { ariaValueTextFormatterForHandle?: AriaValueFormat | AriaValueFormat[]; classNames: SliderClassNames; styles: SliderStyles; + isHandleDisabled: (index: number) => boolean; } const SliderContext = React.createContext({ @@ -32,6 +33,7 @@ const SliderContext = React.createContext({ keyboard: true, styles: {}, classNames: {}, + isHandleDisabled: () => false, }); export default SliderContext; diff --git a/src/hooks/useDisabled.ts b/src/hooks/useDisabled.ts new file mode 100644 index 000000000..a25081a0b --- /dev/null +++ b/src/hooks/useDisabled.ts @@ -0,0 +1,40 @@ +import * as React from 'react'; + +const useDisabled = ( + rawDisabled: boolean | boolean[], +): [ + isHandleDisabled: (index: number) => boolean, + getDisabledState: (rawValues: number[]) => [disabled: boolean, hasDisabledHandle: boolean], +] => { + const isHandleDisabled = React.useCallback( + (index: number) => { + if (typeof rawDisabled === 'boolean') { + return rawDisabled; + } + + return rawDisabled[index] ?? false; + }, + [rawDisabled], + ); + + const getDisabledState = React.useCallback( + (rawValues: number[]): [disabled: boolean, hasDisabledHandle: boolean] => { + if (typeof rawDisabled === 'boolean') { + return [rawDisabled, rawDisabled && rawValues.length > 0]; + } + + return [ + rawValues.length > 0 && rawValues.every((_, index) => isHandleDisabled(index)), + rawValues.some((_, index) => isHandleDisabled(index)), + ]; + }, + [rawDisabled, isHandleDisabled], + ); + + return React.useMemo(() => [isHandleDisabled, getDisabledState], [ + isHandleDisabled, + getDisabledState, + ]); +}; + +export default useDisabled; diff --git a/src/hooks/useDrag.ts b/src/hooks/useDrag.ts index dcdcf61d1..99455d735 100644 --- a/src/hooks/useDrag.ts +++ b/src/hooks/useDrag.ts @@ -26,6 +26,7 @@ function useDrag( offsetValues: OffsetValues, editable: boolean, minCount: number, + isHandleDisabled: (index: number) => boolean, ): [ draggingIndex: number, draggingValue: number, @@ -85,6 +86,7 @@ function useDrag( } triggerChange(changeValues); + // Optional callback for drag change (not used in current implementation) if (onDragChange) { onDragChange({ rawValues: nextValues, @@ -99,6 +101,11 @@ function useDrag( (valueIndex: number, offsetPercent: number, deleteMark: boolean) => { if (valueIndex === -1) { // >>>> Dragging on the track + // Defensive: should not happen as Tracks/index.tsx blocks this when any handle is disabled + if (originValues.some((_, index) => isHandleDisabled(index))) { + return; + } + const startValue = originValues[0]; const endValue = originValues[originValues.length - 1]; const maxStartOffset = min - startValue; @@ -132,8 +139,12 @@ function useDrag( const onStartMove: OnStartMove = (e, valueIndex, startValues?: number[]) => { e.stopPropagation(); - // 如果是点击 track 触发的,需要传入变化后的初始值,而不能直接用 rawValues const initialValues = startValues || rawValues; + // Defensive: should not happen as Handle.tsx blocks this when handle is disabled + if (isHandleDisabled(valueIndex)) { + return; + } + const originValue = initialValues[valueIndex]; setDraggingIndex(valueIndex); @@ -147,7 +158,7 @@ function useDrag( // We declare it here since closure can't get outer latest value let deleteMark = false; - // Internal trigger event + // Optional callback for drag start (not used in current implementation) if (onDragStart) { onDragStart({ rawValues: initialValues, diff --git a/src/hooks/useOffset.ts b/src/hooks/useOffset.ts index 83fa3c57e..75a729268 100644 --- a/src/hooks/useOffset.ts +++ b/src/hooks/useOffset.ts @@ -11,6 +11,7 @@ type FormatStepValue = (value: number) => number; type FormatValue = (value: number) => number; type OffsetMode = 'unit' | 'dist'; +type IsHandleDisabled = (index: number) => boolean; type OffsetValue = ( values: number[], @@ -29,6 +30,82 @@ export type OffsetValues = ( values: number[]; }; +/** + * Get the effective moving range for a handle. + * Disabled handles are treated as fixed anchors, and `pushable` is applied as the gap + * that enabled handles must keep away from those anchors. + */ +export const getDisabledBoundaryValues = ( + values: number[], + valueIndex: number, + min: number, + max: number, + pushable: false | number, + isHandleDisabled: IsHandleDisabled, +): [number, number] => { + const pushGap = typeof pushable === 'number' ? pushable : 0; + let minBound = min; + let maxBound = max; + + for (let i = valueIndex - 1; i >= 0; i -= 1) { + if (isHandleDisabled(i)) { + minBound = values[i] + pushGap; + break; + } + } + + for (let i = valueIndex + 1; i < values.length; i += 1) { + if (isHandleDisabled(i)) { + maxBound = values[i] - pushGap; + break; + } + } + + return [minBound, maxBound]; +}; + +/** + * Find the nearest enabled handle that can accept the target value. + * A handle is only considered when the target value falls inside its disabled-anchor + * boundaries, so clicking outside an enabled segment becomes a no-op. + */ +export const getClosestEnabledHandleIndex = ( + values: number[], + targetValue: number, + min: number, + max: number, + pushable: false | number, + isHandleDisabled: IsHandleDisabled, +) => { + let closestIndex = -1; + let closestDist = max - min; + + values.forEach((value, index) => { + if (isHandleDisabled(index)) { + return; + } + + const [minBound, maxBound] = getDisabledBoundaryValues( + values, + index, + min, + max, + pushable, + isHandleDisabled, + ); + + if (minBound <= targetValue && targetValue <= maxBound) { + const dist = Math.abs(targetValue - value); + if (dist <= closestDist) { + closestDist = dist; + closestIndex = index; + } + } + }); + + return closestIndex; +}; + export default function useOffset( min: number, max: number, @@ -36,6 +113,7 @@ export default function useOffset( markList: InternalMarkObj[], allowCross: boolean, pushable: false | number, + isHandleDisabled: IsHandleDisabled, ): [FormatValue, OffsetValues] { const formatRangeValue: FormatRangeValue = React.useCallback( (val) => Math.max(min, Math.min(max, val)), @@ -196,9 +274,25 @@ export default function useOffset( const offsetValues: OffsetValues = (values, offset, valueIndex, mode = 'unit') => { const nextValues = values.map(formatValue); const originValue = nextValues[valueIndex]; + + const [minBound, maxBound] = getDisabledBoundaryValues( + nextValues, + valueIndex, + min, + max, + pushable, + isHandleDisabled, + ); + const nextValue = offsetValue(nextValues, offset, valueIndex, mode); nextValues[valueIndex] = nextValue; + if (minBound <= maxBound) { + nextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, nextValues[valueIndex])); + } else { + nextValues[valueIndex] = originValue; + } + if (allowCross === false) { // >>>>> Allow Cross const pushNum = pushable || 0; @@ -224,35 +318,83 @@ export default function useOffset( // >>>>>> Basic push // End values for (let i = valueIndex + 1; i < nextValues.length; i += 1) { + if (isHandleDisabled(i)) { + break; + } let changed = true; while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i], changed } = offsetChangedValue(nextValues, 1, i)); } + const [, itemMaxBound] = getDisabledBoundaryValues( + nextValues, + i, + min, + max, + pushable, + isHandleDisabled, + ); + nextValues[i] = Math.min(nextValues[i], itemMaxBound); } // Start values for (let i = valueIndex; i > 0; i -= 1) { + if (isHandleDisabled(i - 1)) { + break; + } let changed = true; while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); } + const [itemMinBound] = getDisabledBoundaryValues( + nextValues, + i - 1, + min, + max, + pushable, + isHandleDisabled, + ); + nextValues[i - 1] = Math.max(nextValues[i - 1], itemMinBound); } // >>>>> Revert back to safe push range // End to Start for (let i = nextValues.length - 1; i > 0; i -= 1) { + if (isHandleDisabled(i) || isHandleDisabled(i - 1)) { + continue; + } let changed = true; while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); } + const [itemMinBound] = getDisabledBoundaryValues( + nextValues, + i - 1, + min, + max, + pushable, + isHandleDisabled, + ); + nextValues[i - 1] = Math.max(nextValues[i - 1], itemMinBound); } // Start to End for (let i = 0; i < nextValues.length - 1; i += 1) { + if (isHandleDisabled(i) || isHandleDisabled(i + 1)) { + continue; + } let changed = true; while (needPush(nextValues[i + 1] - nextValues[i]) && changed) { ({ value: nextValues[i + 1], changed } = offsetChangedValue(nextValues, 1, i + 1)); } + const [, itemMaxBound] = getDisabledBoundaryValues( + nextValues, + i + 1, + min, + max, + pushable, + isHandleDisabled, + ); + nextValues[i + 1] = Math.min(nextValues[i + 1], itemMaxBound); } } diff --git a/tests/Range.test.tsx b/tests/Range.test.tsx index 9b767162f..1f655af90 100644 --- a/tests/Range.test.tsx +++ b/tests/Range.test.tsx @@ -30,8 +30,9 @@ describe('Range', () => { start: number, element = 'rc-slider-handle', skipEventCheck = false, + index = 0, ) { - const ele = container.getElementsByClassName(element)[0]; + const ele = container.getElementsByClassName(element)[index]; const mouseDown = createEvent.mouseDown(ele); (mouseDown as any).pageX = start; (mouseDown as any).pageY = start; @@ -65,8 +66,9 @@ describe('Range', () => { start: number, end: number, element = 'rc-slider-handle', + index = 0, ) { - doMouseDown(container, start, element); + doMouseDown(container, start, element, false, index); // Drag doMouseDrag(end); @@ -841,4 +843,212 @@ describe('Range', () => { expect(onChange).toHaveBeenCalledWith([0, 50]); }); }); + + describe('disabled as array', () => { + const getHandle = (container: HTMLElement, index = 0) => + container.getElementsByClassName('rc-slider-handle')[index] as HTMLElement; + + const repeatKeyDown = (element: Element, code: number, count: number) => { + for (let i = 0; i < count; i += 1) { + fireEvent.keyDown(element, { keyCode: code }); + } + }; + + const getLastChange = (onChange: jest.Mock) => + onChange.mock.calls[onChange.mock.calls.length - 1][0]; + + it('respects handle disabled state and boolean disabled fallback', () => { + const onChange = jest.fn(); + const { container, rerender } = render( + , + ); + + const disabledHandle = getHandle(container, 0); + const enabledHandle = getHandle(container, 1); + + expect(disabledHandle).not.toHaveAttribute('tabIndex'); + expect(disabledHandle).toHaveAttribute('aria-disabled', 'true'); + expect(enabledHandle).toHaveAttribute('tabIndex'); + expect(enabledHandle).toHaveAttribute('aria-disabled', 'false'); + + fireEvent.keyDown(disabledHandle, { keyCode: keyCode.RIGHT }); + doMouseMove(container, 0, 80, 'rc-slider-handle'); + fireEvent.mouseUp(document); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(enabledHandle, { keyCode: keyCode.RIGHT }); + expect(onChange).toHaveBeenCalledWith([0, 51, 100]); + + rerender(); + expect(getHandle(container, 0)).not.toHaveAttribute('tabIndex'); + doMouseDown(container, 30, 'rc-slider', true); + fireEvent.mouseUp(document); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('moves only eligible handles when clicking the track', () => { + const onChange = jest.fn(); + const { container, rerender } = render( + , + ); + + doMouseDown(container, 10, 'rc-slider', true); + fireEvent.mouseUp(document); + expect(onChange).toHaveBeenCalledWith([0, 10, 100]); + + onChange.mockClear(); + rerender(); + doMouseDown(container, 10, 'rc-slider', true); + fireEvent.mouseUp(document); + expect(onChange).not.toHaveBeenCalled(); + + rerender(); + doMouseDown(container, 90, 'rc-slider', true); + fireEvent.mouseUp(document); + expect(onChange).not.toHaveBeenCalled(); + + rerender(); + doMouseDown(container, 10, 'rc-slider', true); + fireEvent.mouseUp(document); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('disables editable operations when any handle is disabled', () => { + const onChange = jest.fn(); + const { container, rerender } = render( + , + ); + + const handle = getHandle(container, 0); + fireEvent.mouseEnter(handle); + fireEvent.keyDown(handle, { keyCode: keyCode.DELETE }); + expect(onChange).not.toHaveBeenCalled(); + + doMouseDown(container, 25, 'rc-slider', true); + fireEvent.mouseUp(document); + expect(onChange).toHaveBeenCalledWith([25, 50, 100]); + + onChange.mockClear(); + rerender( + , + ); + + doMouseDown(container, 40, 'rc-slider', true); + fireEvent.mouseUp(document); + expect(onChange).toHaveBeenCalledWith([20, 40]); + }); + + it('disables draggableTrack only when rendered handles are disabled', () => { + const onChange = jest.fn(); + const { container, unmount } = render( + , + ); + + const track = container.getElementsByClassName('rc-slider-track')[0]; + const mouseDown = createEvent.mouseDown(track); + Object.defineProperties(mouseDown, { + clientX: { get: () => 0 }, + clientY: { get: () => 0 }, + }); + fireEvent(track, mouseDown); + + const mouseMove = createEvent.mouseMove(document); + (mouseMove as any).pageX = 20; + (mouseMove as any).pageY = 20; + fireEvent(document, mouseMove); + fireEvent.mouseUp(document); + + expect(onChange).not.toHaveBeenCalled(); + + unmount(); + + const onMove = jest.fn(); + const { container: enabledContainer } = render( + , + ); + + doMouseMove(enabledContainer, 20, 30, 'rc-slider-track'); + fireEvent.mouseUp(document); + expect(onMove).toHaveBeenCalledWith([30, 70]); + }); + + it('keeps keyboard movement inside disabled handle boundaries', () => { + const onChange = jest.fn(); + const { container, unmount } = render( + , + ); + + repeatKeyDown(getHandle(container, 0), keyCode.RIGHT, 50); + expect(getLastChange(onChange)[0]).toBeLessThanOrEqual(50); + + unmount(); + onChange.mockClear(); + + const { container: boundaryContainer } = render( + , + ); + const middleHandle = getHandle(boundaryContainer, 1); + + middleHandle.focus(); + fireEvent.keyDown(middleHandle, { keyCode: keyCode.HOME }); + expect(onChange).toHaveBeenCalledWith([20, 20, 80]); + expect(getHandle(boundaryContainer, 1)).toHaveFocus(); + + onChange.mockClear(); + fireEvent.keyDown(getHandle(boundaryContainer, 1), { keyCode: keyCode.END }); + expect(onChange).toHaveBeenCalledWith([20, 80, 80]); + }); + + it('respects pushable boundaries around disabled handles', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + repeatKeyDown(getHandle(container, 0), keyCode.UP, 30); + expect(getLastChange(onChange)[0]).toBe(30); + expect(getLastChange(onChange)[1]).toBe(40); + + onChange.mockClear(); + + repeatKeyDown(getHandle(container, 3), keyCode.LEFT, 50); + expect(getLastChange(onChange)[2]).toBe(50); + expect(getLastChange(onChange)[2] - getLastChange(onChange)[1]).toBe(10); + }); + + it('respects disabled boundaries with allowCross=false and step=null', () => { + const onChange = jest.fn(); + const { container, unmount } = render( + , + ); + + repeatKeyDown(getHandle(container, 0), keyCode.RIGHT, 50); + expect(getLastChange(onChange)[0]).toBeLessThanOrEqual(50); + + unmount(); + onChange.mockClear(); + + const { container: stepContainer } = render( + , + ); + + fireEvent.keyDown(getHandle(stepContainer, 0), { keyCode: keyCode.RIGHT }); + expect(getLastChange(onChange)[0]).toBeLessThanOrEqual(50); + }); + }); }); diff --git a/tests/Slider.test.js b/tests/Slider.test.js index c7b6f0376..290a30768 100644 --- a/tests/Slider.test.js +++ b/tests/Slider.test.js @@ -511,6 +511,18 @@ describe('Slider', () => { expect(container.getElementsByClassName('rc-slider-track')).toHaveLength(1); }); + it('should not change null value when disabled', () => { + const onChange = jest.fn(); + const { container } = render(); + + fireEvent.mouseDown(container.querySelector('.rc-slider'), { + clientX: 20, + }); + + expect(onChange).not.toHaveBeenCalled(); + expect(container.getElementsByClassName('rc-slider-track')).toHaveLength(0); + }); + describe('click slider to change value', () => { it('ltr', () => { const onChange = jest.fn();