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 (
+
+ 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.
+
+ 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.
+
+ 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);
+ });
+ });
});