diff --git a/README.md b/README.md index 76577985f..014bd8a74 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 disabled is an array with any `true` value, `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..a1bc02b89 --- /dev/null +++ b/docs/examples/disabled-handle.tsx @@ -0,0 +1,169 @@ +/* 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]); + + 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 + Draggable Track

+

+ Toggle checkboxes to disable/enable specific handles. Drag the track area to move the range. +

+ +
+ +
+

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 903d7233a..c872d2e1c 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' = null; // Change the value @@ -161,12 +165,12 @@ const Handle = React.forwardRef((props, ref) => { if (valueIndex !== null) { divProps = { - tabIndex: disabled ? null : getIndex(tabIndex, valueIndex), + tabIndex: mergedDisabled ? null : getIndex(tabIndex, valueIndex), 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 448a8bf5b..d0b4880fe 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -12,6 +12,7 @@ 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 useRange from './hooks/useRange'; @@ -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) { @@ -198,6 +199,8 @@ const Slider = React.forwardRef>((prop // ============================ Range ============================= const [rangeEnabled, rangeEditable, rangeDraggableTrack, minCount, maxCount] = useRange(range); + + const mergedMin = React.useMemo(() => (isFinite(min) ? min : 0), [min]); const mergedMax = React.useMemo(() => (isFinite(max) ? max : 100), [max]); @@ -239,6 +242,17 @@ const Slider = React.forwardRef>((prop .sort((a, b) => a.value - b.value); }, [marks]); + // ============================ Disabled ============================ + const isHandleDisabled = React.useCallback( + (index: number): boolean => { + if (typeof rawDisabled === 'boolean') { + return rawDisabled; + } + return rawDisabled[index] ?? false; + }, + [rawDisabled], + ); + // ============================ Format ============================ const [formatValue, offsetValues] = useOffset( mergedMin, @@ -247,18 +261,18 @@ const Slider = React.forwardRef>((prop markList, allowCross, mergedPush, + 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]; @@ -288,6 +302,10 @@ const Slider = React.forwardRef>((prop return returnValues; }, [mergedValue, rangeEnabled, mergedMin, count, formatValue]); + const [disabled, hasDisabledHandle] = useDisabled(rawDisabled, rawValues); + + const effectiveRangeEditable = rangeEditable && !hasDisabledHandle; + // =========================== onChange =========================== const getTriggerValue = (triggerValues: number[]) => rangeEnabled ? triggerValues : triggerValues[0]; @@ -321,7 +339,7 @@ const Slider = React.forwardRef>((prop }); const onDelete = (index: number) => { - if (disabled || !rangeEditable || rawValues.length <= minCount) { + if (disabled || !effectiveRangeEditable || rawValues.length <= minCount) { return; } @@ -346,8 +364,9 @@ const Slider = React.forwardRef>((prop triggerChange, finishChange, offsetValues, - rangeEditable, + effectiveRangeEditable, minCount, + isHandleDisabled, ); /** @@ -377,11 +396,50 @@ 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; + // Find nearest enabled handle if current is disabled + if (isHandleDisabled(valueIndex)) { + const enabledIndices = rawValues + .map((_, i) => i) + .filter((i) => !isHandleDisabled(i)); + + if (enabledIndices.length === 0) return; + + valueIndex = enabledIndices.reduce((nearest, i) => + Math.abs(newValue - rawValues[i]) < Math.abs(newValue - rawValues[nearest]) ? i : nearest, + ); + } + + // Calculate boundaries from disabled handles (treat as fixed anchors) + const pushDist = typeof mergedPush === 'number' ? mergedPush : 0; + let minBound = mergedMin; + let maxBound = mergedMax; + + // Find nearest disabled handle on the left as min boundary + for (let i = valueIndex - 1; i >= 0; i -= 1) { + if (isHandleDisabled(i)) { + minBound = Math.max(minBound, rawValues[i] + pushDist); + break; + } + } + + // Find nearest disabled handle on the right as max boundary + for (let i = valueIndex + 1; i < rawValues.length; i += 1) { + if (isHandleDisabled(i)) { + maxBound = Math.min(maxBound, rawValues[i] - pushDist); + break; + } + } + + if (minBound <= maxBound) { + cloneNextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, newValue)); + } else { + cloneNextValues[valueIndex] = rawValues[valueIndex]; + } + focusIndex = valueIndex; } // Fill value to match default 2 (only when `rawValues` is empty) @@ -443,7 +501,7 @@ const Slider = React.forwardRef>((prop const [keyboardValue, setKeyboardValue] = React.useState(null); const onHandleOffsetChange = (offset: number | 'min' | 'max', valueIndex: number) => { - if (!disabled) { + if (!disabled && !isHandleDisabled(valueIndex)) { const next = offsetValues(rawValues, offset, valueIndex); onBeforeChange?.(getTriggerValue(rawValues)); @@ -546,6 +604,7 @@ const Slider = React.forwardRef>((prop ariaValueTextFormatterForHandle, styles: styles || {}, classNames: classNames || {}, + isHandleDisabled, }), [ mergedMin, @@ -565,6 +624,7 @@ const Slider = React.forwardRef>((prop ariaValueTextFormatterForHandle, styles, classNames, + isHandleDisabled, ], ); @@ -620,7 +680,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 4242bb86c..5e04da3a3 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..35324b636 --- /dev/null +++ b/src/hooks/useDisabled.ts @@ -0,0 +1,29 @@ +import * as React from 'react'; + +const useDisabled = ( + rawDisabled: boolean | boolean[], + rawValue: number[], +): [boolean, boolean] => { + + const disabledArray = React.useMemo(() => { + if (typeof rawDisabled === 'boolean') { + return rawValue.map(() => rawDisabled); + } + return Array.isArray(rawDisabled) ? rawDisabled : rawValue.map(() => false); + }, [rawDisabled, rawValue]); + + const disabled = React.useMemo(() => { + return rawValue.length > 0 && rawValue.every((_, index) => disabledArray[index]); + }, [disabledArray, rawValue]); + + const hasDisabledHandle = React.useMemo(() => { + return disabledArray.some((d) => d); + }, [disabledArray]); + + return [ + disabled, + hasDisabledHandle, + ]; +}; + +export default useDisabled; \ No newline at end of file diff --git a/src/hooks/useDrag.ts b/src/hooks/useDrag.ts index 46c4fac06..1ae29d663 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, @@ -77,6 +78,7 @@ function useDrag( } triggerChange(changeValues); + // Optional callback for drag change (not used in current implementation) if (onDragChange) { onDragChange({ rawValues: nextValues, @@ -91,6 +93,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; @@ -124,8 +131,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); @@ -139,7 +150,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 3c54d06be..3220138f7 100644 --- a/src/hooks/useOffset.ts +++ b/src/hooks/useOffset.ts @@ -36,6 +36,7 @@ export default function useOffset( markList: InternalMarkObj[], allowCross: boolean, pushable: false | number, + isHandleDisabled: (index: number) => boolean, ): [FormatValue, OffsetValues] { const formatRangeValue: FormatRangeValue = React.useCallback( (val) => Math.max(min, Math.min(max, val)), @@ -189,13 +190,54 @@ export default function useOffset( return (pushable === null && dist === 0) || (typeof pushable === 'number' && dist < pushable); }; + const gap = typeof pushable === 'number' ? pushable : 0; + + // Get the minimum boundary for a handle considering disabled handles as fixed anchors + const getHandleMinBound = (values: number[], handleIndex: number): number => { + // Collect min and all left-side disabled handle positions as candidates + const candidates = [min]; + for (let i = handleIndex - 1; i >= 0; i -= 1) { + if (isHandleDisabled(i)) { + candidates.push(values[i] + gap); + break; // Only need the nearest disabled handle + } + } + return Math.max(...candidates); + }; + + // Get the maximum boundary for a handle considering disabled handles as fixed anchors + const getHandleMaxBound = (values: number[], handleIndex: number): number => { + // Collect max and all right-side disabled handle positions as candidates + const candidates = [max]; + for (let i = handleIndex + 1; i < values.length; i += 1) { + if (isHandleDisabled(i)) { + candidates.push(values[i] - gap); + break; // Only need the nearest disabled handle + } + } + return Math.min(...candidates); + }; + // Values const offsetValues: OffsetValues = (values, offset, valueIndex, mode = 'unit') => { const nextValues = values.map(formatValue); const originValue = nextValues[valueIndex]; + + const minBound = getHandleMinBound(nextValues, valueIndex); + const maxBound = getHandleMaxBound(nextValues, valueIndex); + const nextValue = offsetValue(nextValues, offset, valueIndex, mode); nextValues[valueIndex] = nextValue; + // Apply disabled handle boundaries + // If bounds conflict (min > max), the handle is locked between two disabled handles + // In this case, keep the original value + 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; @@ -219,37 +261,57 @@ export default function useOffset( // =============== Push ================== // >>>>>> Basic push - // End values + // End values (skip disabled handles) for (let i = valueIndex + 1; i < nextValues.length; i += 1) { + if (isHandleDisabled(i)) { + break; // Stop pushing when hitting a disabled handle + } let changed = true; while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i], changed } = offsetChangedValue(nextValues, 1, i)); } + // Apply boundary constraint to pushed handle + nextValues[i] = Math.min(nextValues[i], getHandleMaxBound(nextValues, i)); } - // Start values + // Start values (skip disabled handles) for (let i = valueIndex; i > 0; i -= 1) { + if (isHandleDisabled(i - 1)) { + break; // Stop pushing when hitting a disabled handle + } let changed = true; while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); } + // Apply boundary constraint to pushed handle + nextValues[i - 1] = Math.max(nextValues[i - 1], getHandleMinBound(nextValues, i - 1)); } // >>>>> Revert back to safe push range - // End to Start + // End to Start (skip disabled handles) for (let i = nextValues.length - 1; i > 0; i -= 1) { + if (isHandleDisabled(i) || isHandleDisabled(i - 1)) { + continue; // Skip if either handle is disabled + } let changed = true; while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); } + // Apply boundary constraint to pushed handle + nextValues[i - 1] = Math.max(nextValues[i - 1], getHandleMinBound(nextValues, i - 1)); } - // Start to End + // Start to End (skip disabled handles) for (let i = 0; i < nextValues.length - 1; i += 1) { + if (isHandleDisabled(i) || isHandleDisabled(i + 1)) { + continue; // Skip if either handle is disabled + } let changed = true; while (needPush(nextValues[i + 1] - nextValues[i]) && changed) { ({ value: nextValues[i + 1], changed } = offsetChangedValue(nextValues, 1, i + 1)); } + // Apply boundary constraint to pushed handle + nextValues[i + 1] = Math.min(nextValues[i + 1], getHandleMaxBound(nextValues, i + 1)); } } diff --git a/tests/Range.test.tsx b/tests/Range.test.tsx index 3bf6e8973..290021c39 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); @@ -839,4 +841,241 @@ describe('Range', () => { expect(onChange).toHaveBeenCalledWith([0, 50]); }); }); + + describe('disabled as array', () => { + it('basic functionality with boolean and array', () => { + const onChange = jest.fn(); + const { container, rerender } = render( + , + ); + + // Disabled handles: no tabIndex, aria-disabled=true + expect(container.getElementsByClassName('rc-slider-handle')[0]).not.toHaveAttribute('tabIndex'); + expect(container.getElementsByClassName('rc-slider-handle')[0]).toHaveAttribute('aria-disabled', 'true'); + + // Enabled handle: has tabIndex, aria-disabled=false + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute('tabIndex'); + expect(container.getElementsByClassName('rc-slider-handle')[1]).toHaveAttribute('aria-disabled', 'false'); + + // Keyboard: disabled handle should not respond + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { keyCode: keyCode.RIGHT }); + expect(onChange).not.toHaveBeenCalled(); + + // Keyboard: enabled handle should respond + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { keyCode: keyCode.RIGHT }); + expect(onChange).toHaveBeenCalledWith([0, 51, 100]); + + // Boolean disabled backward compatibility + rerender(); + expect(container.getElementsByClassName('rc-slider-handle')[0]).not.toHaveAttribute('tabIndex'); + doMouseDown(container, 30, 'rc-slider', true); + expect(onChange).toHaveBeenCalledTimes(1); // Still 1, not triggered + }); + + it('drag and click respect disabled state', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Drag disabled handle - no change + doMouseMove(container, 0, 80, 'rc-slider-handle'); + expect(onChange).not.toHaveBeenCalled(); + + // Click near disabled handle - moves nearest enabled handle + doMouseDown(container, 10, 'rc-slider', true); + expect(onChange).toHaveBeenCalledWith([0, 10, 100]); + }); + + it('cannot cross disabled handle boundary', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Try to move first handle past disabled middle handle + for (let i = 0; i < 50; i++) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { keyCode: keyCode.RIGHT }); + } + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0][0]).toBeLessThanOrEqual(50); + }); + + it('editable mode disabled when any handle is disabled', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Cannot delete handle when any handle is disabled (editable is disabled) + const handle = container.getElementsByClassName('rc-slider-handle')[0]; + fireEvent.mouseEnter(handle); + fireEvent.keyDown(handle, { keyCode: keyCode.DELETE }); + expect(onChange).not.toHaveBeenCalled(); + + // Clicking track moves nearest enabled handle instead of adding new one + // (editable is disabled, so it falls back to normal behavior) + doMouseDown(container, 25, 'rc-slider', true); + // Should move first handle to position 25 instead of adding new handle + expect(onChange).toHaveBeenCalledWith([25, 50, 100]); + }); + + it('editable mode completely disabled when any handle is disabled', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Clicking track moves nearest enabled handle (editable is disabled) + doMouseDown(container, 40, 'rc-slider', true); + // Should move second handle to position 40 instead of adding new handle + expect(onChange).toHaveBeenCalledWith([20, 40]); + }); + + it('all handles disabled prevents interaction', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Click does nothing (covers findNearestEnabled returning -1) + const rail = container.querySelector('.rc-slider-rail'); + const mouseDown = createEvent.mouseDown(rail); + Object.defineProperties(mouseDown, { + clientX: { get: () => 10 }, + clientY: { get: () => 10 }, + }); + fireEvent(rail, mouseDown); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('draggableTrack disabled when any handle is disabled', () => { + const onChange = jest.fn(); + const { container } = 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); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('click to move respects disabled boundary', () => { + const onChange = jest.fn(); + const { container, rerender } = render( + , + ); + + // Click left of disabled handle - clamped to boundary + doMouseDown(container, 10, 'rc-slider', true); + expect(onChange).toHaveBeenCalledWith([20, 20, 80]); + + // Click right of disabled handle - clamped to boundary + rerender(); + doMouseDown(container, 90, 'rc-slider', true); + expect(onChange).toHaveBeenCalledWith([20, 80, 80]); + }); + + it('pushable respects disabled handle boundaries', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Push first handle right - should stop at disabled handle boundary (40 - 10 = 30) + for (let i = 0; i < 30; i++) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { keyCode: keyCode.UP }); + } + + // First handle should stop at 30 to maintain pushable distance from disabled handle at 40 + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0][0]).toBe(30); + expect(lastCall[0][1]).toBe(40); + }); + + it('pushable revert respects disabled handles', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Drag last handle (80) left to push third handle (60) toward disabled handle (40) + for (let i = 0; i < 50; i++) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[3], { keyCode: keyCode.LEFT }); + } + + // Third handle should maintain pushable distance from disabled handle at 40 + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0][2]).toBe(50); + expect(lastCall[0][2] - lastCall[0][1]).toBe(10); + }); + + it('keyboard home/end with disabled handles', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // HOME key on enabled middle handle - should go to left disabled handle boundary (20) + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { keyCode: keyCode.HOME }); + expect(onChange).toHaveBeenCalledWith([20, 20, 80]); + + onChange.mockClear(); + + // END key on enabled middle handle - should go to right disabled handle boundary (80) + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[1], { keyCode: keyCode.END }); + expect(onChange).toHaveBeenCalledWith([20, 80, 80]); + }); + + it('allowCross false with disabled handles', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Try to move first handle past disabled middle handle + for (let i = 0; i < 50; i++) { + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { keyCode: keyCode.RIGHT }); + } + + // First handle should not cross disabled handle at 50 + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[0][0]).toBeLessThanOrEqual(50); + }); + + it('pushable with step=null and disabled handles', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Push first handle right - should stop at disabled handle + fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[0], { keyCode: keyCode.RIGHT }); + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + // First handle should not exceed disabled handle boundary (50) minus pushable + expect(lastCall[0][0]).toBeLessThanOrEqual(50); + }); + }); });