From 0667d6fa196b0860936628783ad91766afa28425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 15 Apr 2026 13:02:55 +0800 Subject: [PATCH 01/23] feat: add support for disabling specific slider handles and update styles --- assets/index.less | 14 +++ docs/demo/disabled-handle.md | 9 ++ docs/examples/disabled-handle.tsx | 113 ++++++++++++++++++++ src/Handles/Handle.tsx | 19 ++-- src/Slider.tsx | 65 ++++++++++-- src/Tracks/index.tsx | 14 ++- src/context.ts | 1 + src/hooks/useDrag.ts | 15 ++- src/hooks/useOffset.ts | 25 +++++ tests/Range.test.tsx | 169 +++++++++++++++++++++++++++++- 10 files changed, 425 insertions(+), 19 deletions(-) create mode 100644 docs/demo/disabled-handle.md create mode 100644 docs/examples/disabled-handle.tsx 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..5c9a1fd9f --- /dev/null +++ b/docs/examples/disabled-handle.tsx @@ -0,0 +1,113 @@ +/* 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, +}; + +// Basic editable with disabled handles +const EditableWithDisabled = () => { + const [value, setValue] = useState([0, 30, 100]); + const [disabled, setDisabled] = useState([true, false, true]); + + return ( +
+ setValue(v as number[])} + disabled={disabled} + /> +
+ {value.map((val, index) => ( + + ))} +
+

+ Try: Click track to add handle • Drag handle to edge to delete • Toggle checkboxes to disable handles +

+
+ ); +}; + +const BasicDisabledHandle = () => { + const [value, setValue] = useState([0, 30, 60, 100]); + const [disabled, setDisabled] = useState([true]); + + return ( +
+ setValue(v as number[])} disabled={disabled} /> +
+ {value.map((val, index) => ( + + ))} +
+
+ ); +}; + +const DisabledHandleAsBoundary = () => { + const [value, setValue] = useState([10, 50, 90]); + + return ( +
+ setValue(v as number[])} 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. +

+
+ ); +}; + +export default () => ( +
+
+

Disabled Handle + Draggable Track

+

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

+ +
+ +
+

Disabled Handle as Boundary

+ +
+
+
+

Editable + Disabled Array

+

Toggle checkboxes to enable/disable handles in editable mode

+ +
+
+
+); diff --git a/src/Handles/Handle.tsx b/src/Handles/Handle.tsx index 903d7233a..20401bc40 100644 --- a/src/Handles/Handle.tsx +++ b/src/Handles/Handle.tsx @@ -55,7 +55,7 @@ const Handle = React.forwardRef((props, ref) => { min, max, direction, - disabled, + disabled: globalDisabled, keyboard, range, tabIndex, @@ -65,15 +65,21 @@ const Handle = React.forwardRef((props, ref) => { ariaValueTextFormatterForHandle, styles, classNames, + isHandleDisabled, } = React.useContext(SliderContext); + const handleDisabled = + globalDisabled || (isHandleDisabled ? isHandleDisabled(valueIndex) : false); + const handlePrefixCls = `${prefixCls}-handle`; // ============================ Events ============================ const onInternalStartMove = (e: React.MouseEvent | React.TouchEvent) => { - if (!disabled) { - onStartMove(e, valueIndex); + if (handleDisabled) { + e.stopPropagation(); + return; } + onStartMove(e, valueIndex); }; const onInternalFocus = (e: React.FocusEvent) => { @@ -86,7 +92,7 @@ const Handle = React.forwardRef((props, ref) => { // =========================== Keyboard =========================== const onKeyDown: React.KeyboardEventHandler = (e) => { - if (!disabled && keyboard) { + if (!handleDisabled && keyboard) { let offset: number | 'min' | 'max' = null; // Change the value @@ -161,12 +167,12 @@ const Handle = React.forwardRef((props, ref) => { if (valueIndex !== null) { divProps = { - tabIndex: disabled ? null : getIndex(tabIndex, valueIndex), + tabIndex: handleDisabled ? null : getIndex(tabIndex, valueIndex), role: 'slider', 'aria-valuemin': min, 'aria-valuemax': max, 'aria-valuenow': value, - 'aria-disabled': disabled, + 'aria-disabled': handleDisabled, 'aria-label': getIndex(ariaLabelForHandle, valueIndex), 'aria-labelledby': getIndex(ariaLabelledByForHandle, valueIndex), 'aria-required': getIndex(ariaRequired, valueIndex), @@ -190,6 +196,7 @@ const Handle = React.forwardRef((props, ref) => { [`${handlePrefixCls}-${valueIndex + 1}`]: valueIndex !== null && range, [`${handlePrefixCls}-dragging`]: dragging, [`${handlePrefixCls}-dragging-delete`]: draggingDelete, + [`${handlePrefixCls}-disabled`]: handleDisabled, }, classNames.handle, )} diff --git a/src/Slider.tsx b/src/Slider.tsx index 448a8bf5b..7611ceee3 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -57,7 +57,7 @@ export interface SliderProps { id?: string; // Status - disabled?: boolean; + disabled?: boolean | boolean[]; keyboard?: boolean; autoFocus?: boolean; onFocus?: (e: React.FocusEvent) => void; @@ -131,8 +131,7 @@ const Slider = React.forwardRef>((prop id, - // Status - disabled = false, + disabled: rawDisabled = false, keyboard = true, autoFocus, onFocus, @@ -188,6 +187,25 @@ const Slider = React.forwardRef>((prop const handlesRef = React.useRef(null); const containerRef = React.useRef(null); + // ============================ Disabled ============================ + const disabled = React.useMemo(() => { + if (typeof rawDisabled === 'boolean') { + return rawDisabled; + }; + + return Array.isArray(value) && value.length === rawDisabled.length && rawDisabled.every(Boolean); + }, [rawDisabled, value]); + + const isHandleDisabled = React.useCallback( + (index: number) => { + if (typeof rawDisabled === 'boolean') { + return rawDisabled; + } + return rawDisabled[index] || false; + }, + [rawDisabled], + ); + const direction = React.useMemo(() => { if (vertical) { return reverse ? 'ttb' : 'btt'; @@ -247,6 +265,7 @@ const Slider = React.forwardRef>((prop markList, allowCross, mergedPush, + isHandleDisabled, ); // ============================ Values ============================ @@ -257,8 +276,8 @@ const Slider = React.forwardRef>((prop mergedValue === null || mergedValue === undefined ? [] : Array.isArray(mergedValue) - ? mergedValue - : [mergedValue]; + ? mergedValue + : [mergedValue]; const [val0 = mergedMin] = valueList; let returnValues = mergedValue === null ? [] : [val0]; @@ -321,7 +340,7 @@ const Slider = React.forwardRef>((prop }); const onDelete = (index: number) => { - if (disabled || !rangeEditable || rawValues.length <= minCount) { + if (disabled || !rangeEditable || rawValues.length <= minCount || isHandleDisabled(index)) { return; } @@ -348,6 +367,7 @@ const Slider = React.forwardRef>((prop offsetValues, rangeEditable, minCount, + isHandleDisabled, ); /** @@ -378,10 +398,39 @@ const Slider = React.forwardRef>((prop let focusIndex = valueIndex; if (rangeEditable && valueDist !== 0 && (!maxCount || rawValues.length < maxCount)) { + const leftDisabled = isHandleDisabled(valueBeforeIndex); + const rightDisabled = isHandleDisabled(valueBeforeIndex + 1); + + if (leftDisabled && rightDisabled) { + return; + } + cloneNextValues.splice(valueBeforeIndex + 1, 0, newValue); focusIndex = valueBeforeIndex + 1; } else { + if (isHandleDisabled(valueIndex)) { + let nearestIndex = -1; + let nearestDist = mergedMax - mergedMin; + + rawValues.forEach((val, index) => { + if (!isHandleDisabled(index)) { + const dist = Math.abs(newValue - val); + if (dist < nearestDist) { + nearestDist = dist; + nearestIndex = index; + } + } + }); + + // If all handles are disabled, do nothing + if (nearestIndex === -1) { + return; + } + + valueIndex = nearestIndex; + } cloneNextValues[valueIndex] = newValue; + focusIndex = valueIndex; } // Fill value to match default 2 (only when `rawValues` is empty) @@ -443,7 +492,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 +595,7 @@ const Slider = React.forwardRef>((prop ariaValueTextFormatterForHandle, styles: styles || {}, classNames: classNames || {}, + isHandleDisabled, }), [ mergedMin, @@ -565,6 +615,7 @@ const Slider = React.forwardRef>((prop ariaValueTextFormatterForHandle, styles, classNames, + isHandleDisabled, ], ); diff --git a/src/Tracks/index.tsx b/src/Tracks/index.tsx index 4242bb86c..c5da214f5 100644 --- a/src/Tracks/index.tsx +++ b/src/Tracks/index.tsx @@ -14,8 +14,18 @@ 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(() => { + if (!isHandleDisabled) return false; + for (let i = 0; i < values.length; i++) { + if (isHandleDisabled(i)) return true; + } + return false; + }, [isHandleDisabled, values.length]); + + const onStartMove = hasDisabledHandle ? undefined : propsOnStartMove; // =========================== List =========================== const trackList = React.useMemo(() => { diff --git a/src/context.ts b/src/context.ts index 9a0901c7a..8807c3892 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({ diff --git a/src/hooks/useDrag.ts b/src/hooks/useDrag.ts index 46c4fac06..b2134fda2 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 (isHandleDisabled && 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 && 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..04805ab67 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)), @@ -193,9 +194,33 @@ export default function useOffset( const offsetValues: OffsetValues = (values, offset, valueIndex, mode = 'unit') => { const nextValues = values.map(formatValue); const originValue = nextValues[valueIndex]; + + let minBound = min; + let maxBound = max; + + if (isHandleDisabled) { + for (let i = valueIndex - 1; i >= 0; i -= 1) { + if (isHandleDisabled(i)) { + minBound = nextValues[i]; + break; + } + } + for (let i = valueIndex + 1; i < nextValues.length; i += 1) { + if (isHandleDisabled(i)) { + maxBound = nextValues[i]; + break; + } + } + } + const nextValue = offsetValue(nextValues, offset, valueIndex, mode); nextValues[valueIndex] = nextValue; + // Apply disabled handle boundaries + if (isHandleDisabled) { + nextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, nextValues[valueIndex])); + } + if (allowCross === false) { // >>>>> Allow Cross const pushNum = pushable || 0; diff --git a/tests/Range.test.tsx b/tests/Range.test.tsx index 3bf6e8973..e2aa86211 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,167 @@ describe('Range', () => { expect(onChange).toHaveBeenCalledWith([0, 50]); }); }); + + describe('disabled as array', () => { + it('basic', () => { + const onChange = jest.fn(); + const { container } = 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]); + }); + + it('drag disabled handle', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Try to drag disabled first handle + doMouseMove(container, 20, 80, 'rc-slider-handle'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('click slider to move nearest enabled handle', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Click near disabled handle at 0, should move enabled handle at 50 + doMouseDown(container, 10, 'rc-slider', true); + expect(onChange).toHaveBeenCalledWith([0, 10, 100]); + }); + + it('cannot cross disabled handle', () => { + 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: cannot delete disabled handle', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Try to delete disabled middle handle + const handle = container.getElementsByClassName('rc-slider-handle')[1]; + fireEvent.mouseEnter(handle); + fireEvent.keyDown(handle, { keyCode: keyCode.DELETE }); + expect(onChange).not.toHaveBeenCalled(); + + // Try to drag out disabled handle + doMouseMove(container, 50, 1000, 'rc-slider-handle', 1); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('backward compatible with boolean', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + expect(container.getElementsByClassName('rc-slider-handle')[0]).not.toHaveAttribute('tabIndex'); + doMouseDown(container, 30, 'rc-slider', true); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('editable: cannot add handle between two disabled handles', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Click between 20 and 50, both are disabled + doMouseDown(container, 35, 'rc-slider', true); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('all handles disabled: click does nothing', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + const rail = container.querySelector('.rc-slider-rail'); + const mouseDown = createEvent.mouseDown(rail); + Object.defineProperties(mouseDown, { + clientX: { get: () => 30 }, + clientY: { get: () => 30 }, + }); + fireEvent(rail, mouseDown); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('draggableTrack disabled when any handle is disabled', () => { + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Try to drag track - should not work because one handle is disabled + 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); + + // Drag + const mouseMove = createEvent.mouseMove(document); + (mouseMove as any).pageX = 20; + (mouseMove as any).pageY = 20; + fireEvent(document, mouseMove); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('all handles disabled: find nearest enabled returns -1', () => { + // This test specifically covers line 426 in Slider.tsx + // When all handles are disabled and clicking near one, + // the nearestIndex search returns -1 and returns early + const onChange = jest.fn(); + const { container } = render( + , + ); + + // Click at position 10 (near first disabled handle) + const rail = container.querySelector('.rc-slider-rail'); + const mouseDown = createEvent.mouseDown(rail); + Object.defineProperties(mouseDown, { + clientX: { get: () => 10 }, + clientY: { get: () => 10 }, + }); + fireEvent(rail, mouseDown); + + // Should not trigger onChange because all handles are disabled + expect(onChange).not.toHaveBeenCalled(); + }); + }); }); From 52c7208ed01f5306bfe5234407248ab113649df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 15 Apr 2026 14:05:38 +0800 Subject: [PATCH 02/23] feat: add onDisabledChange callback to sync disabled handles in editable mode --- docs/examples/disabled-handle.tsx | 21 +++++++++++ src/Slider.tsx | 29 ++++++++++++++- tests/Range.test.tsx | 61 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/docs/examples/disabled-handle.tsx b/docs/examples/disabled-handle.tsx index 5c9a1fd9f..669f964c2 100644 --- a/docs/examples/disabled-handle.tsx +++ b/docs/examples/disabled-handle.tsx @@ -24,6 +24,7 @@ const EditableWithDisabled = () => { value={value} onChange={(v) => setValue(v as number[])} disabled={disabled} + onDisabledChange={setDisabled} />
{value.map((val, index) => ( @@ -90,8 +91,28 @@ const DisabledHandleAsBoundary = () => { ); }; +const SingleSlider = () => { + const [value1, setValue1] = useState(30); + const [value2, setValue2] = useState(30); + + return ( +
+ setValue1(v as number)} disabled /> +
+ setValue2(v as number)} disabled={false} /> +
+ ); +} + export default () => (
+
+ single handle disabled + +
+
+ +

Disabled Handle + Draggable Track

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

diff --git a/src/Slider.tsx b/src/Slider.tsx index 7611ceee3..93cafaa8c 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -78,6 +78,8 @@ export interface SliderProps { /** @deprecated Use `onChangeComplete` instead */ onAfterChange?: (value: ValueType) => void; onChangeComplete?: (value: ValueType) => void; + /** Callback when disabled array needs to be updated (e.g., when handles are added/removed in editable mode) */ + onDisabledChange?: (disabled: boolean[]) => void; // Cross allowCross?: boolean; @@ -149,6 +151,7 @@ const Slider = React.forwardRef>((prop onBeforeChange, onAfterChange, onChangeComplete, + onDisabledChange, // Cross allowCross = true, @@ -191,9 +194,12 @@ const Slider = React.forwardRef>((prop const disabled = React.useMemo(() => { if (typeof rawDisabled === 'boolean') { return rawDisabled; - }; + } + if (Array.isArray(value)) { + return value.every((_, index) => rawDisabled[index]); + } - return Array.isArray(value) && value.length === rawDisabled.length && rawDisabled.every(Boolean); + return false; }, [rawDisabled, value]); const isHandleDisabled = React.useCallback( @@ -315,6 +321,25 @@ const Slider = React.forwardRef>((prop // Order first const cloneNextValues = [...nextValues].sort((a, b) => a - b); + // Sync disabled array when values length changes (add/remove handles in editable mode) + if ( + typeof rawDisabled !== 'boolean' && + Array.isArray(rawDisabled) && + cloneNextValues.length !== rawValues.length + ) { + const newDisabled = [...rawDisabled]; + + if (cloneNextValues.length > rawValues.length) { + const index = cloneNextValues.findIndex((item) => !rawValues.includes(item)); + newDisabled.splice(index, 0, false); + } else if (cloneNextValues.length < rawValues.length) { + const index = rawValues.findIndex((item) => !cloneNextValues.includes(item)); + newDisabled.splice(index, 1); + } + + onDisabledChange?.(newDisabled); + } + // Trigger event if needed if (onChange && !isEqual(cloneNextValues, rawValues, true)) { onChange(getTriggerValue(cloneNextValues)); diff --git a/tests/Range.test.tsx b/tests/Range.test.tsx index e2aa86211..e096def23 100644 --- a/tests/Range.test.tsx +++ b/tests/Range.test.tsx @@ -1003,5 +1003,66 @@ describe('Range', () => { // Should not trigger onChange because all handles are disabled expect(onChange).not.toHaveBeenCalled(); }); + + it('editable: onDisabledChange called when adding handle', () => { + const onChange = jest.fn(); + const onDisabledChange = jest.fn(); + const { container } = render( + , + ); + + // Click to add a handle between 0 and 100 + doMouseDown(container, 50, 'rc-slider', true); + + expect(onChange).toHaveBeenCalledWith([0, 50, 100]); + expect(onDisabledChange).toHaveBeenCalledWith([true, false, false]); + }); + + it('editable: onDisabledChange called when removing handle', () => { + const onChange = jest.fn(); + const onDisabledChange = jest.fn(); + const { container } = render( + , + ); + + // Drag first handle (enabled) out to delete it + doMouseMove(container, 0, 1000); + + expect(onChange).toHaveBeenCalledWith([50, 100]); + expect(onDisabledChange).toHaveBeenCalledWith([true, false]); + }); + + it('editable: disabled array stays in sync when adding between disabled handles', () => { + const onChange = jest.fn(); + const onDisabledChange = jest.fn(); + const { container } = render( + , + ); + + // Click to add a handle between 20 and 60 + doMouseDown(container, 40, 'rc-slider', true); + + // Should not trigger onChange because both surrounding handles are disabled + expect(onChange).not.toHaveBeenCalled(); + expect(onDisabledChange).not.toHaveBeenCalled(); + }); }); }); From 530358497f663cbb4b6e03126d875979b853b7aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 15 Apr 2026 14:27:23 +0800 Subject: [PATCH 03/23] docs: update README and example for disabled array feature - Update README.md with disabled array and onDisabledChange documentation - Fix BasicDisabledHandle example to handle sparse disabled array Co-Authored-By: Claude Opus 4.6 --- README.md | 3 ++- docs/examples/disabled-handle.tsx | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 76577985f..0a3c56705 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,13 @@ 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. Can also be an array to disable specific handles in range mode, e.g. `[true, false, true]` disables first and third handles. | | 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. | | onChange | Function | NOOP | `onChange` will be triggered while the value of Slider changing. | | onChangeComplete | Function | NOOP | `onChangeComplete` will be triggered when `ontouchend` or `onmouseup` is triggered. | +| onDisabledChange | (disabled: boolean[]) => void | - | Callback when disabled array needs to be updated (e.g., when handles are added/removed in editable mode). Use with `disabled` as array to keep disabled states in sync. | | minimumTrackStyle | Object | | please use `trackStyle` instead. (`only used for slider, just for compatibility , will be deprecate at rc-slider@9.x `) | | maximumTrackStyle | Object | | please use `railStyle` instead (`only used for slider, just for compatibility , will be deprecate at rc-slider@9.x`) | | handleStyle | Array[Object] \| Object | `[{}]` | The style used for handle. (`both for slider(`Object`) and range(`Array of Object`), the array will be used for multi handle following element order`) | diff --git a/docs/examples/disabled-handle.tsx b/docs/examples/disabled-handle.tsx index 669f964c2..1a3e33050 100644 --- a/docs/examples/disabled-handle.tsx +++ b/docs/examples/disabled-handle.tsx @@ -26,12 +26,13 @@ const EditableWithDisabled = () => { disabled={disabled} onDisabledChange={setDisabled} /> + Slider disabled {JSON.stringify(disabled)}
{value.map((val, index) => (
+ +
+

Disabled Handle + Pushable

+ +

Editable + Disabled Array

diff --git a/src/Slider.tsx b/src/Slider.tsx index 9bab50583..e96d7ba37 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -461,13 +461,15 @@ const Slider = React.forwardRef>((prop for (let i = valueIndex - 1; i >= 0; i -= 1) { if (isHandleDisabled(i)) { - minBound = rawValues[i]; + const pushDistance = typeof mergedPush === 'number' ? mergedPush : 0; + minBound = rawValues[i] + pushDistance; break; } } for (let i = valueIndex + 1; i < rawValues.length; i += 1) { if (isHandleDisabled(i)) { - maxBound = rawValues[i]; + const pushDistance = typeof mergedPush === 'number' ? mergedPush : 0; + maxBound = rawValues[i] - pushDistance; break; } } diff --git a/src/hooks/useOffset.ts b/src/hooks/useOffset.ts index 1b4b76d30..d30ba089a 100644 --- a/src/hooks/useOffset.ts +++ b/src/hooks/useOffset.ts @@ -190,28 +190,35 @@ export default function useOffset( return (pushable === null && dist === 0) || (typeof pushable === 'number' && dist < pushable); }; + // Get the minimum boundary for a handle considering disabled handles + const getHandleMinBound = (values: number[], handleIndex: number): number => { + if (!isHandleDisabled) return min; + for (let i = handleIndex - 1; i >= 0; i -= 1) { + if (isHandleDisabled(i)) { + return values[i] + (typeof pushable === 'number' ? pushable : 0); + } + } + return min; + }; + + // Get the maximum boundary for a handle considering disabled handles + const getHandleMaxBound = (values: number[], handleIndex: number): number => { + if (!isHandleDisabled) return max; + for (let i = handleIndex + 1; i < values.length; i += 1) { + if (isHandleDisabled(i)) { + return values[i] - (typeof pushable === 'number' ? pushable : 0); + } + } + return max; + }; + // Values const offsetValues: OffsetValues = (values, offset, valueIndex, mode = 'unit') => { const nextValues = values.map(formatValue); const originValue = nextValues[valueIndex]; - let minBound = min; - let maxBound = max; - - if (isHandleDisabled) { - for (let i = valueIndex - 1; i >= 0; i -= 1) { - if (isHandleDisabled(i)) { - minBound = nextValues[i]; - break; - } - } - for (let i = valueIndex + 1; i < nextValues.length; i += 1) { - if (isHandleDisabled(i)) { - maxBound = nextValues[i]; - break; - } - } - } + const minBound = getHandleMinBound(nextValues, valueIndex); + const maxBound = getHandleMaxBound(nextValues, valueIndex); const nextValue = offsetValue(nextValues, offset, valueIndex, mode); nextValues[valueIndex] = nextValue; @@ -253,6 +260,10 @@ export default function useOffset( while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i], changed } = offsetChangedValue(nextValues, 1, i)); } + // Apply boundary constraint to pushed handle + if (isHandleDisabled) { + nextValues[i] = Math.min(nextValues[i], getHandleMaxBound(nextValues, i)); + } } // Start values (skip disabled handles) @@ -264,6 +275,10 @@ export default function useOffset( while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); } + // Apply boundary constraint to pushed handle + if (isHandleDisabled) { + nextValues[i - 1] = Math.max(nextValues[i - 1], getHandleMinBound(nextValues, i - 1)); + } } // >>>>> Revert back to safe push range @@ -276,6 +291,10 @@ export default function useOffset( while (needPush(nextValues[i] - nextValues[i - 1]) && changed) { ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); } + // Apply boundary constraint to pushed handle + if (isHandleDisabled) { + nextValues[i - 1] = Math.max(nextValues[i - 1], getHandleMinBound(nextValues, i - 1)); + } } // Start to End (skip disabled handles) @@ -287,6 +306,10 @@ export default function useOffset( while (needPush(nextValues[i + 1] - nextValues[i]) && changed) { ({ value: nextValues[i + 1], changed } = offsetChangedValue(nextValues, 1, i + 1)); } + // Apply boundary constraint to pushed handle + if (isHandleDisabled) { + 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 ff9a7317f..e2fcac97e 100644 --- a/tests/Range.test.tsx +++ b/tests/Range.test.tsx @@ -1020,33 +1020,35 @@ describe('Range', () => { it('pushable respects disabled handle boundaries', () => { const onChange = jest.fn(); const { container } = render( - , + , ); - // Push first handle right - should stop at disabled handle (40) + // 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 not exceed 40 (position of disabled handle) + // 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]).toBeLessThanOrEqual(40); - // Second handle is disabled at 40 + 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( - , + , ); - // Move last handle left - should push third handle but stop at disabled - fireEvent.keyDown(container.getElementsByClassName('rc-slider-handle')[3], { keyCode: keyCode.LEFT }); + // 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 not go below disabled handle at 20 + // 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]).toBeGreaterThanOrEqual(20); + expect(lastCall[0][2]).toBe(50); + expect(lastCall[0][2] - lastCall[0][1]).toBe(10); }); it('keyboard home/end with disabled handles', () => { @@ -1081,5 +1083,19 @@ describe('Range', () => { 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); + }); }); }); From ea5b72e912ac3fd316780abeb144f55decf5bdf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Fri, 17 Apr 2026 15:13:43 +0800 Subject: [PATCH 10/23] refactor: simplify disabled handle implementation - Remove onDisabledChange callback and related sync logic - Disable editable mode when any handle is disabled - Disabled handles act as fixed boundaries that cannot be crossed - Update examples and tests to reflect new behavior Co-Authored-By: Claude Opus 4.6 --- README.md | 3 +- docs/examples/disabled-handle.tsx | 101 ++++++++++++++++-------------- src/Slider.tsx | 48 +++++--------- tests/Range.test.tsx | 48 +++++--------- 4 files changed, 83 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 80bb59ad3..014bd8a74 100644 --- a/README.md +++ b/README.md @@ -108,13 +108,12 @@ 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 \| 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. | +| 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. | | onChange | Function | NOOP | `onChange` will be triggered while the value of Slider changing. | | onChangeComplete | Function | NOOP | `onChangeComplete` will be triggered when `ontouchend` or `onmouseup` is triggered. | -| onDisabledChange | (disabled: boolean[]) => void | - | Callback when disabled array needs to be updated (e.g., when handles are added/removed in editable mode). Use with `disabled` as array to keep disabled states in sync. | | minimumTrackStyle | Object | | please use `trackStyle` instead. (`only used for slider, just for compatibility , will be deprecate at rc-slider@9.x `) | | maximumTrackStyle | Object | | please use `railStyle` instead (`only used for slider, just for compatibility , will be deprecate at rc-slider@9.x`) | | handleStyle | Array[Object] \| Object | `[{}]` | The style used for handle. (`both for slider(`Object`) and range(`Array of Object`), the array will be used for multi handle following element order`) | diff --git a/docs/examples/disabled-handle.tsx b/docs/examples/disabled-handle.tsx index 58daad966..696d9c6d2 100644 --- a/docs/examples/disabled-handle.tsx +++ b/docs/examples/disabled-handle.tsx @@ -8,48 +8,6 @@ const style: React.CSSProperties = { margin: 50, }; -// Basic editable with disabled handles -const EditableWithDisabled = () => { - const [value, setValue] = useState([0, 30, 100]); - const [disabled, setDisabled] = useState([true, false, true]); - - return ( -
- setValue(v as number[])} - disabled={disabled} - onDisabledChange={setDisabled} - /> - Slider disabled {JSON.stringify(disabled)} -
- {value.map((val, index) => ( - - ))} -
-

- Try: Click track to add handle • Drag handle to edge to delete • Toggle checkboxes to - disable handles -

-
- ); -}; const defaultValue = [0, 30, 60, 100]; const BasicDisabledHandle = () => { const [disabled, setDisabled] = useState([true]); @@ -85,6 +43,7 @@ const DisabledHandleAsBoundary = () => {
setValue(v as number[])} disabled={[false, true, false]} @@ -130,6 +89,54 @@ const SingleSlider = () => { ); }; +// 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 () => (
@@ -153,12 +160,10 @@ export default () => (

Disabled Handle + Pushable

-
-
-

Editable + Disabled Array

-

Toggle checkboxes to enable/disable handles in editable mode

- -
+ +
+

Editable + Disabled (Editable Disabled When Any Handle Disabled)

+
); diff --git a/src/Slider.tsx b/src/Slider.tsx index e96d7ba37..81c4969dc 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -78,8 +78,6 @@ export interface SliderProps { /** @deprecated Use `onChangeComplete` instead */ onAfterChange?: (value: ValueType) => void; onChangeComplete?: (value: ValueType) => void; - /** Callback when disabled array needs to be updated (e.g., when handles are added/removed in editable mode) */ - onDisabledChange?: (disabled: boolean[]) => void; // Cross allowCross?: boolean; @@ -151,7 +149,6 @@ const Slider = React.forwardRef>((prop onBeforeChange, onAfterChange, onChangeComplete, - onDisabledChange, // Cross allowCross = true, @@ -223,6 +220,17 @@ const Slider = React.forwardRef>((prop // ============================ Range ============================= const [rangeEnabled, rangeEditable, rangeDraggableTrack, minCount, maxCount] = useRange(range); + // Check if any handle is disabled - if so, disable all editable operations + const hasDisabledHandle = React.useMemo(() => { + if (typeof rawDisabled === 'boolean') { + return rawDisabled; + } + return rawDisabled.some((d) => d); + }, [rawDisabled]); + + // Disable editable when any handle is disabled + const effectiveRangeEditable = rangeEditable && !hasDisabledHandle; + const mergedMin = React.useMemo(() => (isFinite(min) ? min : 0), [min]); const mergedMax = React.useMemo(() => (isFinite(max) ? max : 100), [max]); @@ -321,25 +329,6 @@ const Slider = React.forwardRef>((prop // Order first const cloneNextValues = [...nextValues].sort((a, b) => a - b); - // Sync disabled array when values length changes (add/remove handles in editable mode) - if ( - Array.isArray(rawDisabled) && - cloneNextValues.length !== rawValues.length - ) { - const newDisabled = [...rawDisabled]; - - if (cloneNextValues.length > rawValues.length) { - const index = cloneNextValues.findIndex((item, i) => item !== rawValues[i]); - const insertIndex = index === -1 ? rawValues.length : index; - newDisabled.splice(insertIndex, 0, false); - } else if (cloneNextValues.length < rawValues.length) { - const index = rawValues.findIndex((item, i) => item !== cloneNextValues[i]); - newDisabled.splice(index, 1); - } - - onDisabledChange?.(newDisabled); - } - // Trigger event if needed if (onChange && !isEqual(cloneNextValues, rawValues, true)) { onChange(getTriggerValue(cloneNextValues)); @@ -365,7 +354,7 @@ const Slider = React.forwardRef>((prop }); const onDelete = (index: number) => { - if (disabled || !rangeEditable || rawValues.length <= minCount || isHandleDisabled(index)) { + if (disabled || !effectiveRangeEditable || rawValues.length <= minCount || isHandleDisabled(index)) { return; } @@ -390,7 +379,7 @@ const Slider = React.forwardRef>((prop triggerChange, finishChange, offsetValues, - rangeEditable, + effectiveRangeEditable, minCount, isHandleDisabled, ); @@ -422,14 +411,7 @@ const Slider = React.forwardRef>((prop let focusIndex = valueIndex; - if (rangeEditable && valueDist !== 0 && (!maxCount || rawValues.length < maxCount)) { - const leftDisabled = isHandleDisabled(valueBeforeIndex); - const rightDisabled = isHandleDisabled(valueBeforeIndex + 1); - - if (leftDisabled && rightDisabled) { - return; - } - + if (effectiveRangeEditable && valueDist !== 0 && (!maxCount || rawValues.length < maxCount)) { cloneNextValues.splice(valueBeforeIndex + 1, 0, newValue); focusIndex = valueBeforeIndex + 1; } else { @@ -716,7 +698,7 @@ const Slider = React.forwardRef>((prop handleRender={handleRender} activeHandleRender={activeHandleRender} onChangeComplete={finishChange} - onDelete={rangeEditable ? onDelete : undefined} + onDelete={effectiveRangeEditable ? onDelete : undefined} /> diff --git a/tests/Range.test.tsx b/tests/Range.test.tsx index e2fcac97e..290021c39 100644 --- a/tests/Range.test.tsx +++ b/tests/Range.test.tsx @@ -901,65 +901,45 @@ describe('Range', () => { expect(lastCall[0][0]).toBeLessThanOrEqual(50); }); - it('editable mode with disabled handles', () => { + it('editable mode disabled when any handle is disabled', () => { const onChange = jest.fn(); - const onDisabledChange = jest.fn(); const { container } = render( , ); - // Cannot delete disabled handle - const handle = container.getElementsByClassName('rc-slider-handle')[1]; + // 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(); - // Cannot drag out disabled handle - doMouseMove(container, 50, 1000, 'rc-slider-handle', 1); - expect(onChange).not.toHaveBeenCalled(); - - // onDisabledChange called when removing enabled handle - doMouseMove(container, 0, 1000); - expect(onChange).toHaveBeenCalledWith([50, 100]); - expect(onDisabledChange).toHaveBeenCalledWith([true, false]); + // 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: add handle respects disabled boundaries', () => { + it('editable mode completely disabled when any handle is disabled', () => { const onChange = jest.fn(); - const onDisabledChange = jest.fn(); - const { container, rerender } = render( + const { container } = render( , ); + // Clicking track moves nearest enabled handle (editable is disabled) doMouseDown(container, 40, 'rc-slider', true); - expect(onChange).not.toHaveBeenCalled(); - expect(onDisabledChange).not.toHaveBeenCalled(); - - // Can add when only one side is disabled - rerender( - , - ); - doMouseDown(container, 50, 'rc-slider', true); - expect(onChange).toHaveBeenCalledWith([0, 50, 100]); - expect(onDisabledChange).toHaveBeenCalledWith([true, false, false]); + // Should move second handle to position 40 instead of adding new handle + expect(onChange).toHaveBeenCalledWith([20, 40]); }); it('all handles disabled prevents interaction', () => { From 9584ac3d70489814e558983b13b1002279e1d5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Fri, 17 Apr 2026 15:56:47 +0800 Subject: [PATCH 11/23] fix: enforce required isHandleDisabled parameter in useDrag and useOffset hooks --- src/hooks/useDrag.ts | 6 +++--- src/hooks/useOffset.ts | 32 ++++++++++---------------------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/src/hooks/useDrag.ts b/src/hooks/useDrag.ts index b2134fda2..1ae29d663 100644 --- a/src/hooks/useDrag.ts +++ b/src/hooks/useDrag.ts @@ -26,7 +26,7 @@ function useDrag( offsetValues: OffsetValues, editable: boolean, minCount: number, - isHandleDisabled?: (index: number) => boolean, + isHandleDisabled: (index: number) => boolean, ): [ draggingIndex: number, draggingValue: number, @@ -94,7 +94,7 @@ function useDrag( if (valueIndex === -1) { // >>>> Dragging on the track // Defensive: should not happen as Tracks/index.tsx blocks this when any handle is disabled - if (isHandleDisabled && originValues.some((_, index) => isHandleDisabled(index))) { + if (originValues.some((_, index) => isHandleDisabled(index))) { return; } @@ -133,7 +133,7 @@ function useDrag( const initialValues = startValues || rawValues; // Defensive: should not happen as Handle.tsx blocks this when handle is disabled - if (isHandleDisabled && isHandleDisabled(valueIndex)) { + if (isHandleDisabled(valueIndex)) { return; } diff --git a/src/hooks/useOffset.ts b/src/hooks/useOffset.ts index d30ba089a..c649ecb4e 100644 --- a/src/hooks/useOffset.ts +++ b/src/hooks/useOffset.ts @@ -36,7 +36,7 @@ export default function useOffset( markList: InternalMarkObj[], allowCross: boolean, pushable: false | number, - isHandleDisabled?: (index: number) => boolean, + isHandleDisabled: (index: number) => boolean, ): [FormatValue, OffsetValues] { const formatRangeValue: FormatRangeValue = React.useCallback( (val) => Math.max(min, Math.min(max, val)), @@ -192,7 +192,6 @@ export default function useOffset( // Get the minimum boundary for a handle considering disabled handles const getHandleMinBound = (values: number[], handleIndex: number): number => { - if (!isHandleDisabled) return min; for (let i = handleIndex - 1; i >= 0; i -= 1) { if (isHandleDisabled(i)) { return values[i] + (typeof pushable === 'number' ? pushable : 0); @@ -203,7 +202,6 @@ export default function useOffset( // Get the maximum boundary for a handle considering disabled handles const getHandleMaxBound = (values: number[], handleIndex: number): number => { - if (!isHandleDisabled) return max; for (let i = handleIndex + 1; i < values.length; i += 1) { if (isHandleDisabled(i)) { return values[i] - (typeof pushable === 'number' ? pushable : 0); @@ -224,9 +222,7 @@ export default function useOffset( nextValues[valueIndex] = nextValue; // Apply disabled handle boundaries - if (isHandleDisabled) { - nextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, nextValues[valueIndex])); - } + nextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, nextValues[valueIndex])); if (allowCross === false) { // >>>>> Allow Cross @@ -253,7 +249,7 @@ export default function useOffset( // >>>>>> Basic push // End values (skip disabled handles) for (let i = valueIndex + 1; i < nextValues.length; i += 1) { - if (isHandleDisabled?.(i)) { + if (isHandleDisabled(i)) { break; // Stop pushing when hitting a disabled handle } let changed = true; @@ -261,14 +257,12 @@ export default function useOffset( ({ value: nextValues[i], changed } = offsetChangedValue(nextValues, 1, i)); } // Apply boundary constraint to pushed handle - if (isHandleDisabled) { - nextValues[i] = Math.min(nextValues[i], getHandleMaxBound(nextValues, i)); - } + nextValues[i] = Math.min(nextValues[i], getHandleMaxBound(nextValues, i)); } // Start values (skip disabled handles) for (let i = valueIndex; i > 0; i -= 1) { - if (isHandleDisabled?.(i - 1)) { + if (isHandleDisabled(i - 1)) { break; // Stop pushing when hitting a disabled handle } let changed = true; @@ -276,15 +270,13 @@ export default function useOffset( ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); } // Apply boundary constraint to pushed handle - if (isHandleDisabled) { - nextValues[i - 1] = Math.max(nextValues[i - 1], getHandleMinBound(nextValues, i - 1)); - } + nextValues[i - 1] = Math.max(nextValues[i - 1], getHandleMinBound(nextValues, i - 1)); } // >>>>> Revert back to safe push range // End to Start (skip disabled handles) for (let i = nextValues.length - 1; i > 0; i -= 1) { - if (isHandleDisabled?.(i) || isHandleDisabled?.(i - 1)) { + if (isHandleDisabled(i) || isHandleDisabled(i - 1)) { continue; // Skip if either handle is disabled } let changed = true; @@ -292,14 +284,12 @@ export default function useOffset( ({ value: nextValues[i - 1], changed } = offsetChangedValue(nextValues, -1, i - 1)); } // Apply boundary constraint to pushed handle - if (isHandleDisabled) { - nextValues[i - 1] = Math.max(nextValues[i - 1], getHandleMinBound(nextValues, i - 1)); - } + nextValues[i - 1] = Math.max(nextValues[i - 1], getHandleMinBound(nextValues, i - 1)); } // Start to End (skip disabled handles) for (let i = 0; i < nextValues.length - 1; i += 1) { - if (isHandleDisabled?.(i) || isHandleDisabled?.(i + 1)) { + if (isHandleDisabled(i) || isHandleDisabled(i + 1)) { continue; // Skip if either handle is disabled } let changed = true; @@ -307,9 +297,7 @@ export default function useOffset( ({ value: nextValues[i + 1], changed } = offsetChangedValue(nextValues, 1, i + 1)); } // Apply boundary constraint to pushed handle - if (isHandleDisabled) { - nextValues[i + 1] = Math.min(nextValues[i + 1], getHandleMaxBound(nextValues, i + 1)); - } + nextValues[i + 1] = Math.min(nextValues[i + 1], getHandleMaxBound(nextValues, i + 1)); } } From cda5aebc51b7b402beed1b257b550804542529b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Fri, 17 Apr 2026 15:58:17 +0800 Subject: [PATCH 12/23] refactor: make isHandleDisabled required in context - Change isHandleDisabled from optional to required in SliderContextProps - Remove unnecessary null checks in Tracks and Handle components Co-Authored-By: Claude Opus 4.6 --- src/Handles/Handle.tsx | 3 +-- src/Tracks/index.tsx | 1 - src/context.ts | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Handles/Handle.tsx b/src/Handles/Handle.tsx index 20401bc40..f752b6bea 100644 --- a/src/Handles/Handle.tsx +++ b/src/Handles/Handle.tsx @@ -68,8 +68,7 @@ const Handle = React.forwardRef((props, ref) => { isHandleDisabled, } = React.useContext(SliderContext); - const handleDisabled = - globalDisabled || (isHandleDisabled ? isHandleDisabled(valueIndex) : false); + const handleDisabled = globalDisabled || isHandleDisabled(valueIndex); const handlePrefixCls = `${prefixCls}-handle`; diff --git a/src/Tracks/index.tsx b/src/Tracks/index.tsx index c5da214f5..bc158dc9e 100644 --- a/src/Tracks/index.tsx +++ b/src/Tracks/index.tsx @@ -18,7 +18,6 @@ const Tracks: React.FC = (props) => { const { included, range, min, styles, classNames, isHandleDisabled } = React.useContext(SliderContext); const hasDisabledHandle = React.useMemo(() => { - if (!isHandleDisabled) return false; for (let i = 0; i < values.length; i++) { if (isHandleDisabled(i)) return true; } diff --git a/src/context.ts b/src/context.ts index 8807c3892..6b5bfb734 100644 --- a/src/context.ts +++ b/src/context.ts @@ -19,7 +19,7 @@ export interface SliderContextProps { ariaValueTextFormatterForHandle?: AriaValueFormat | AriaValueFormat[]; classNames: SliderClassNames; styles: SliderStyles; - isHandleDisabled?: (index: number) => boolean; + isHandleDisabled: (index: number) => boolean; } const SliderContext = React.createContext({ From 7fae517fc134467846e53454395a20aec89cb0e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Fri, 17 Apr 2026 16:05:18 +0800 Subject: [PATCH 13/23] fix: add missing isHandleDisabled default value in context React.createContext default value was missing isHandleDisabled after making it required in SliderContextProps Co-Authored-By: Claude Opus 4.6 --- src/context.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/context.ts b/src/context.ts index 6b5bfb734..1b13b6fda 100644 --- a/src/context.ts +++ b/src/context.ts @@ -33,6 +33,7 @@ const SliderContext = React.createContext({ keyboard: true, styles: {}, classNames: {}, + isHandleDisabled: () => false, }); export default SliderContext; From cf5819c0e472d3fd1270c1805093e894abbd8449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Mon, 20 Apr 2026 10:16:15 +0800 Subject: [PATCH 14/23] refactor: optimize disabled handle implementation based on review feedback - Handle.tsx: rename handleDisabled to mergedDisabled, remove globalDisabled - Slider.tsx: use ?? operator for fallback to global disabled state - Slider.tsx: simplify boundary calculation with functional style - useOffset.ts: treat disabled handles as fixed anchors with candidates pattern - useOffset.ts: handle dead-lock case when minBound > maxBound Co-Authored-By: zombieJ --- src/Handles/Handle.tsx | 13 ++++----- src/Slider.tsx | 66 ++++++++++++++++++------------------------ src/hooks/useOffset.ts | 28 +++++++++++++----- 3 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/Handles/Handle.tsx b/src/Handles/Handle.tsx index f752b6bea..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: globalDisabled, keyboard, range, tabIndex, @@ -68,13 +67,13 @@ const Handle = React.forwardRef((props, ref) => { isHandleDisabled, } = React.useContext(SliderContext); - const handleDisabled = globalDisabled || isHandleDisabled(valueIndex); + const mergedDisabled = isHandleDisabled(valueIndex); const handlePrefixCls = `${prefixCls}-handle`; // ============================ Events ============================ const onInternalStartMove = (e: React.MouseEvent | React.TouchEvent) => { - if (handleDisabled) { + if (mergedDisabled) { e.stopPropagation(); return; } @@ -91,7 +90,7 @@ const Handle = React.forwardRef((props, ref) => { // =========================== Keyboard =========================== const onKeyDown: React.KeyboardEventHandler = (e) => { - if (!handleDisabled && keyboard) { + if (!mergedDisabled && keyboard) { let offset: number | 'min' | 'max' = null; // Change the value @@ -166,12 +165,12 @@ const Handle = React.forwardRef((props, ref) => { if (valueIndex !== null) { divProps = { - tabIndex: handleDisabled ? null : getIndex(tabIndex, valueIndex), + tabIndex: mergedDisabled ? null : getIndex(tabIndex, valueIndex), role: 'slider', 'aria-valuemin': min, 'aria-valuemax': max, 'aria-valuenow': value, - 'aria-disabled': handleDisabled, + 'aria-disabled': mergedDisabled, 'aria-label': getIndex(ariaLabelForHandle, valueIndex), 'aria-labelledby': getIndex(ariaLabelledByForHandle, valueIndex), 'aria-required': getIndex(ariaRequired, valueIndex), @@ -195,7 +194,7 @@ const Handle = React.forwardRef((props, ref) => { [`${handlePrefixCls}-${valueIndex + 1}`]: valueIndex !== null && range, [`${handlePrefixCls}-dragging`]: dragging, [`${handlePrefixCls}-dragging-delete`]: draggingDelete, - [`${handlePrefixCls}-disabled`]: handleDisabled, + [`${handlePrefixCls}-disabled`]: mergedDisabled, }, classNames.handle, )} diff --git a/src/Slider.tsx b/src/Slider.tsx index 81c4969dc..3b5e918e6 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -205,9 +205,10 @@ const Slider = React.forwardRef>((prop if (typeof rawDisabled === 'boolean') { return rawDisabled; } - return rawDisabled[index] || false; + // Return individual disabled state if defined, otherwise fallback to global disabled + return rawDisabled[index] ?? disabled; }, - [rawDisabled], + [rawDisabled, disabled], ); const direction = React.useMemo(() => { @@ -415,47 +416,36 @@ const Slider = React.forwardRef>((prop cloneNextValues.splice(valueBeforeIndex + 1, 0, newValue); focusIndex = valueBeforeIndex + 1; } else { + // Find nearest enabled handle if current is disabled if (isHandleDisabled(valueIndex)) { - let nearestIndex = -1; - let nearestDist = mergedMax - mergedMin; - - rawValues.forEach((val, index) => { - if (!isHandleDisabled(index)) { - const dist = Math.abs(newValue - val); - if (dist < nearestDist) { - nearestDist = dist; - nearestIndex = index; - } - } - }); - - // If all handles are disabled, do nothing - if (nearestIndex === -1) { - return; - } - - valueIndex = nearestIndex; - } + const enabledIndices = rawValues + .map((_, i) => i) + .filter((i) => !isHandleDisabled(i)); - // Apply boundary constraints from disabled handles - let minBound = mergedMin; - let maxBound = mergedMax; + if (enabledIndices.length === 0) return; - for (let i = valueIndex - 1; i >= 0; i -= 1) { - if (isHandleDisabled(i)) { - const pushDistance = typeof mergedPush === 'number' ? mergedPush : 0; - minBound = rawValues[i] + pushDistance; - break; - } - } - for (let i = valueIndex + 1; i < rawValues.length; i += 1) { - if (isHandleDisabled(i)) { - const pushDistance = typeof mergedPush === 'number' ? mergedPush : 0; - maxBound = rawValues[i] - pushDistance; - break; - } + 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; + const minBound = Math.max( + mergedMin, + ...rawValues + .slice(0, valueIndex) + .map((v, i) => (isHandleDisabled(i) ? v + pushDist : mergedMin)) + .filter((v) => v > mergedMin), + ); + const maxBound = Math.min( + mergedMax, + ...rawValues + .slice(valueIndex + 1) + .map((v, i) => (isHandleDisabled(i + valueIndex + 1) ? v - pushDist : mergedMax)) + .filter((v) => v < mergedMax), + ); + cloneNextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, newValue)); focusIndex = valueIndex; } diff --git a/src/hooks/useOffset.ts b/src/hooks/useOffset.ts index c649ecb4e..831618a42 100644 --- a/src/hooks/useOffset.ts +++ b/src/hooks/useOffset.ts @@ -190,24 +190,32 @@ export default function useOffset( return (pushable === null && dist === 0) || (typeof pushable === 'number' && dist < pushable); }; - // Get the minimum boundary for a handle considering disabled handles + // Get the minimum boundary for a handle considering disabled handles as fixed anchors const getHandleMinBound = (values: number[], handleIndex: number): number => { + const gap = typeof pushable === 'number' ? pushable : 0; + // 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)) { - return values[i] + (typeof pushable === 'number' ? pushable : 0); + candidates.push(values[i] + gap); + break; // Only need the nearest disabled handle } } - return min; + return Math.max(...candidates); }; - // Get the maximum boundary for a handle considering disabled handles + // Get the maximum boundary for a handle considering disabled handles as fixed anchors const getHandleMaxBound = (values: number[], handleIndex: number): number => { + const gap = typeof pushable === 'number' ? pushable : 0; + // 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)) { - return values[i] - (typeof pushable === 'number' ? pushable : 0); + candidates.push(values[i] - gap); + break; // Only need the nearest disabled handle } } - return max; + return Math.min(...candidates); }; // Values @@ -222,7 +230,13 @@ export default function useOffset( nextValues[valueIndex] = nextValue; // Apply disabled handle boundaries - nextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, nextValues[valueIndex])); + // 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 From fbb2822311c91edd4236f66805c5df4c6817604a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Mon, 20 Apr 2026 17:16:33 +0800 Subject: [PATCH 15/23] refactor: extract useDisabled hook to unify disabled handling - Extract disabled logic into useDisabled hook - Add type annotations for TypeScript inference - Extract gap variable in useOffset to avoid duplication Co-Authored-By: Claude Opus 4.6 --- src/Slider.tsx | 30 ++------------------------ src/hooks/useDisabled.ts | 46 ++++++++++++++++++++++++++++++++++++++++ src/hooks/useOffset.ts | 4 ++-- 3 files changed, 50 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useDisabled.ts diff --git a/src/Slider.tsx b/src/Slider.tsx index 3b5e918e6..d09acb322 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'; @@ -189,27 +190,8 @@ const Slider = React.forwardRef>((prop const [mergedValue, setValue] = useControlledState(defaultValue, value); // ============================ Disabled ============================ - const disabled = React.useMemo(() => { - if (typeof rawDisabled === 'boolean') { - return rawDisabled; - } - if (Array.isArray(rawDisabled)) { - const values = Array.isArray(mergedValue) ? mergedValue : [mergedValue]; - return values.every((_, index) => rawDisabled[index]); - } - return false; - }, [rawDisabled, mergedValue]); - const isHandleDisabled = React.useCallback( - (index: number) => { - if (typeof rawDisabled === 'boolean') { - return rawDisabled; - } - // Return individual disabled state if defined, otherwise fallback to global disabled - return rawDisabled[index] ?? disabled; - }, - [rawDisabled, disabled], - ); + const [disabled, isHandleDisabled, hasDisabledHandle] = useDisabled(rawDisabled, mergedValue); const direction = React.useMemo(() => { if (vertical) { @@ -221,14 +203,6 @@ const Slider = React.forwardRef>((prop // ============================ Range ============================= const [rangeEnabled, rangeEditable, rangeDraggableTrack, minCount, maxCount] = useRange(range); - // Check if any handle is disabled - if so, disable all editable operations - const hasDisabledHandle = React.useMemo(() => { - if (typeof rawDisabled === 'boolean') { - return rawDisabled; - } - return rawDisabled.some((d) => d); - }, [rawDisabled]); - // Disable editable when any handle is disabled const effectiveRangeEditable = rangeEditable && !hasDisabledHandle; diff --git a/src/hooks/useDisabled.ts b/src/hooks/useDisabled.ts new file mode 100644 index 000000000..3ba8e4d09 --- /dev/null +++ b/src/hooks/useDisabled.ts @@ -0,0 +1,46 @@ +import * as React from 'react'; + +const useDisabled = ( + rawDisabled: boolean | boolean[], + mergedValue?: number | number[], +): [boolean, (index: number) => boolean, boolean] => { + + const disabledIsArray = Array.isArray(rawDisabled); + const disabledIsBoolean = typeof rawDisabled === 'boolean'; + const values = React.useMemo( + () => (Array.isArray(mergedValue) ? mergedValue : [mergedValue]), + [mergedValue], + ); + + const disabled = React.useMemo(() => { + if (disabledIsBoolean) { + return rawDisabled; + } + return disabledIsArray ? values.every((_, index) => rawDisabled[index]) : false; + }, [rawDisabled, mergedValue]); + + const isHandleDisabled = React.useCallback( + (index: number): boolean => { + if (disabledIsBoolean) { + return rawDisabled; + } + return rawDisabled[index] ?? disabled; + }, + [rawDisabled, disabled], + ); + + const hasDisabledHandle = React.useMemo(() => { + if (disabledIsBoolean) { + return rawDisabled; + } + return rawDisabled.some((d) => d); + }, [rawDisabled]); + + return [ + disabled, + isHandleDisabled, + hasDisabledHandle, + ]; +}; + +export default useDisabled; \ No newline at end of file diff --git a/src/hooks/useOffset.ts b/src/hooks/useOffset.ts index 831618a42..3220138f7 100644 --- a/src/hooks/useOffset.ts +++ b/src/hooks/useOffset.ts @@ -190,9 +190,10 @@ 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 => { - const gap = typeof pushable === 'number' ? pushable : 0; // Collect min and all left-side disabled handle positions as candidates const candidates = [min]; for (let i = handleIndex - 1; i >= 0; i -= 1) { @@ -206,7 +207,6 @@ export default function useOffset( // Get the maximum boundary for a handle considering disabled handles as fixed anchors const getHandleMaxBound = (values: number[], handleIndex: number): number => { - const gap = typeof pushable === 'number' ? pushable : 0; // Collect max and all right-side disabled handle positions as candidates const candidates = [max]; for (let i = handleIndex + 1; i < values.length; i += 1) { From bc3a9100fb82798ff4c2ccab5f7b0e8ef48873af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 22 Apr 2026 10:38:59 +0800 Subject: [PATCH 16/23] refactor: optimize disabled handle boundary calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify boundary calculation with for loop instead of map/filter chain - Unify disabled array handling in useDisabled hook to avoid repetitive checks - Remove duplicate disabledIsBoolean checks by converting upfront 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/Slider.tsx | 32 ++++++++++++++++++-------------- src/hooks/useDisabled.ts | 28 ++++++++++++---------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/Slider.tsx b/src/Slider.tsx index d09acb322..c6c70c4c3 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -405,20 +405,24 @@ const Slider = React.forwardRef>((prop // Calculate boundaries from disabled handles (treat as fixed anchors) const pushDist = typeof mergedPush === 'number' ? mergedPush : 0; - const minBound = Math.max( - mergedMin, - ...rawValues - .slice(0, valueIndex) - .map((v, i) => (isHandleDisabled(i) ? v + pushDist : mergedMin)) - .filter((v) => v > mergedMin), - ); - const maxBound = Math.min( - mergedMax, - ...rawValues - .slice(valueIndex + 1) - .map((v, i) => (isHandleDisabled(i + valueIndex + 1) ? v - pushDist : mergedMax)) - .filter((v) => v < mergedMax), - ); + 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; + } + } cloneNextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, newValue)); focusIndex = valueIndex; diff --git a/src/hooks/useDisabled.ts b/src/hooks/useDisabled.ts index 3ba8e4d09..32cc1884d 100644 --- a/src/hooks/useDisabled.ts +++ b/src/hooks/useDisabled.ts @@ -5,36 +5,32 @@ const useDisabled = ( mergedValue?: number | number[], ): [boolean, (index: number) => boolean, boolean] => { - const disabledIsArray = Array.isArray(rawDisabled); - const disabledIsBoolean = typeof rawDisabled === 'boolean'; const values = React.useMemo( () => (Array.isArray(mergedValue) ? mergedValue : [mergedValue]), [mergedValue], ); - const disabled = React.useMemo(() => { - if (disabledIsBoolean) { - return rawDisabled; + const disabledArray = React.useMemo(() => { + if (typeof rawDisabled === 'boolean') { + return values.map(() => rawDisabled); } - return disabledIsArray ? values.every((_, index) => rawDisabled[index]) : false; + return Array.isArray(rawDisabled) ? rawDisabled : values.map(() => false); }, [rawDisabled, mergedValue]); + const disabled = React.useMemo(() => { + return values.every((_, index) => disabledArray[index]); + }, [disabledArray, values]); + const isHandleDisabled = React.useCallback( (index: number): boolean => { - if (disabledIsBoolean) { - return rawDisabled; - } - return rawDisabled[index] ?? disabled; + return disabledArray[index] ?? disabled; }, - [rawDisabled, disabled], + [disabledArray, disabled], ); const hasDisabledHandle = React.useMemo(() => { - if (disabledIsBoolean) { - return rawDisabled; - } - return rawDisabled.some((d) => d); - }, [rawDisabled]); + return disabledArray.some((d) => d); + }, [disabledArray]); return [ disabled, From cb346c482d115b6b6cdcad09703ca10140dba1ae Mon Sep 17 00:00:00 2001 From: EmilyyyLiu <100924403+EmilyyyLiu@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:42:09 +0800 Subject: [PATCH 17/23] Update docs/examples/disabled-handle.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/examples/disabled-handle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/disabled-handle.tsx b/docs/examples/disabled-handle.tsx index 696d9c6d2..a1bc02b89 100644 --- a/docs/examples/disabled-handle.tsx +++ b/docs/examples/disabled-handle.tsx @@ -45,7 +45,7 @@ const DisabledHandleAsBoundary = () => { range step={10} value={value} - onChange={(v) => setValue(v as number[])} + onChange={(v) => Array.isArray(v) && setValue(v)} disabled={[false, true, false]} />

From 672a8ab8ff0a6aab7e7efec790147485b7d9f402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 22 Apr 2026 11:50:37 +0800 Subject: [PATCH 18/23] refactor: use Array.some instead of for loop in hasDisabledHandle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/Tracks/index.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Tracks/index.tsx b/src/Tracks/index.tsx index bc158dc9e..5e04da3a3 100644 --- a/src/Tracks/index.tsx +++ b/src/Tracks/index.tsx @@ -17,12 +17,10 @@ const Tracks: React.FC = (props) => { const { prefixCls, style, values, startPoint, onStartMove: propsOnStartMove } = props; const { included, range, min, styles, classNames, isHandleDisabled } = React.useContext(SliderContext); - const hasDisabledHandle = React.useMemo(() => { - for (let i = 0; i < values.length; i++) { - if (isHandleDisabled(i)) return true; - } - return false; - }, [isHandleDisabled, values.length]); + const hasDisabledHandle = React.useMemo( + () => values.some((_, index) => isHandleDisabled(index)), + [isHandleDisabled, values], + ); const onStartMove = hasDisabledHandle ? undefined : propsOnStartMove; From f94b6b1f8049ba87a43504c9b70261b3d63a3b72 Mon Sep 17 00:00:00 2001 From: EmilyyyLiu <100924403+EmilyyyLiu@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:14:35 +0800 Subject: [PATCH 19/23] Update src/Slider.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/Slider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Slider.tsx b/src/Slider.tsx index c6c70c4c3..10fc63105 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -329,7 +329,7 @@ const Slider = React.forwardRef>((prop }); const onDelete = (index: number) => { - if (disabled || !effectiveRangeEditable || rawValues.length <= minCount || isHandleDisabled(index)) { + if (disabled || !effectiveRangeEditable || rawValues.length <= minCount) { return; } From 866486b7b090690a35c8d5027b31f86517de18d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 22 Apr 2026 12:24:24 +0800 Subject: [PATCH 20/23] fix: add boundary validation for click-to-move when handles are locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a handle is positioned between two disabled handles with insufficient space for the required pushable gap, it's now kept at its original position instead of being forced to a boundary value. This ensures consistency with the drag behavior in useOffset.ts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/Slider.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Slider.tsx b/src/Slider.tsx index 10fc63105..466834ded 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -424,7 +424,11 @@ const Slider = React.forwardRef>((prop } } - cloneNextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, newValue)); + if (minBound <= maxBound) { + cloneNextValues[valueIndex] = Math.max(minBound, Math.min(maxBound, newValue)); + } else { + cloneNextValues[valueIndex] = rawValues[valueIndex]; + } focusIndex = valueIndex; } From 98901c89284bf0996f3eed543a01096f56301891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 22 Apr 2026 13:50:56 +0800 Subject: [PATCH 21/23] refactor: simplify useDisabled hook by removing isHandleDisabled --- src/Slider.tsx | 12 +++++++++++- src/hooks/useDisabled.ts | 10 +--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Slider.tsx b/src/Slider.tsx index 466834ded..153df5e15 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -191,7 +191,17 @@ const Slider = React.forwardRef>((prop // ============================ Disabled ============================ - const [disabled, isHandleDisabled, hasDisabledHandle] = useDisabled(rawDisabled, mergedValue); + const [disabled, hasDisabledHandle] = useDisabled(rawDisabled, mergedValue); + + const isHandleDisabled = React.useCallback( + (index: number): boolean => { + if (typeof rawDisabled === 'boolean') { + return rawDisabled; + } + return rawDisabled[index] ?? false; + }, + [rawDisabled], + ); const direction = React.useMemo(() => { if (vertical) { diff --git a/src/hooks/useDisabled.ts b/src/hooks/useDisabled.ts index 32cc1884d..908a7fe3d 100644 --- a/src/hooks/useDisabled.ts +++ b/src/hooks/useDisabled.ts @@ -3,7 +3,7 @@ import * as React from 'react'; const useDisabled = ( rawDisabled: boolean | boolean[], mergedValue?: number | number[], -): [boolean, (index: number) => boolean, boolean] => { +): [boolean, boolean] => { const values = React.useMemo( () => (Array.isArray(mergedValue) ? mergedValue : [mergedValue]), @@ -21,20 +21,12 @@ const useDisabled = ( return values.every((_, index) => disabledArray[index]); }, [disabledArray, values]); - const isHandleDisabled = React.useCallback( - (index: number): boolean => { - return disabledArray[index] ?? disabled; - }, - [disabledArray, disabled], - ); - const hasDisabledHandle = React.useMemo(() => { return disabledArray.some((d) => d); }, [disabledArray]); return [ disabled, - isHandleDisabled, hasDisabledHandle, ]; }; From 93f173452bd37054591a59bfda9d7fb6b250f4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 22 Apr 2026 14:51:44 +0800 Subject: [PATCH 22/23] refactor: streamline useDisabled hook by removing mergedValue parameter --- src/Slider.tsx | 32 ++++++++++++++++---------------- src/hooks/useDisabled.ts | 17 ++++++----------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/Slider.tsx b/src/Slider.tsx index 153df5e15..d0b4880fe 100644 --- a/src/Slider.tsx +++ b/src/Slider.tsx @@ -189,20 +189,6 @@ const Slider = React.forwardRef>((prop const containerRef = React.useRef(null); const [mergedValue, setValue] = useControlledState(defaultValue, value); - // ============================ Disabled ============================ - - const [disabled, hasDisabledHandle] = useDisabled(rawDisabled, mergedValue); - - const isHandleDisabled = React.useCallback( - (index: number): boolean => { - if (typeof rawDisabled === 'boolean') { - return rawDisabled; - } - return rawDisabled[index] ?? false; - }, - [rawDisabled], - ); - const direction = React.useMemo(() => { if (vertical) { return reverse ? 'ttb' : 'btt'; @@ -213,8 +199,7 @@ const Slider = React.forwardRef>((prop // ============================ Range ============================= const [rangeEnabled, rangeEditable, rangeDraggableTrack, minCount, maxCount] = useRange(range); - // Disable editable when any handle is disabled - const effectiveRangeEditable = rangeEditable && !hasDisabledHandle; + const mergedMin = React.useMemo(() => (isFinite(min) ? min : 0), [min]); const mergedMax = React.useMemo(() => (isFinite(max) ? max : 100), [max]); @@ -257,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, @@ -306,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]; diff --git a/src/hooks/useDisabled.ts b/src/hooks/useDisabled.ts index 908a7fe3d..86a18168a 100644 --- a/src/hooks/useDisabled.ts +++ b/src/hooks/useDisabled.ts @@ -2,24 +2,19 @@ import * as React from 'react'; const useDisabled = ( rawDisabled: boolean | boolean[], - mergedValue?: number | number[], + rawValue: number[], ): [boolean, boolean] => { - const values = React.useMemo( - () => (Array.isArray(mergedValue) ? mergedValue : [mergedValue]), - [mergedValue], - ); - const disabledArray = React.useMemo(() => { if (typeof rawDisabled === 'boolean') { - return values.map(() => rawDisabled); + return rawValue.map(() => rawDisabled); } - return Array.isArray(rawDisabled) ? rawDisabled : values.map(() => false); - }, [rawDisabled, mergedValue]); + return Array.isArray(rawDisabled) ? rawDisabled : rawValue.map(() => false); + }, [rawDisabled, rawValue]); const disabled = React.useMemo(() => { - return values.every((_, index) => disabledArray[index]); - }, [disabledArray, values]); + return rawValue.every((_, index) => disabledArray[index]); + }, [disabledArray, rawValue]); const hasDisabledHandle = React.useMemo(() => { return disabledArray.some((d) => d); From 9f9f44df0abb77a6b25ba9fe65c9f52685d63228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Wed, 22 Apr 2026 16:31:10 +0800 Subject: [PATCH 23/23] fix: ensure disabled state is correctly evaluated when rawValue is empty --- src/hooks/useDisabled.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useDisabled.ts b/src/hooks/useDisabled.ts index 86a18168a..35324b636 100644 --- a/src/hooks/useDisabled.ts +++ b/src/hooks/useDisabled.ts @@ -13,7 +13,7 @@ const useDisabled = ( }, [rawDisabled, rawValue]); const disabled = React.useMemo(() => { - return rawValue.every((_, index) => disabledArray[index]); + return rawValue.length > 0 && rawValue.every((_, index) => disabledArray[index]); }, [disabledArray, rawValue]); const hasDisabledHandle = React.useMemo(() => {