From bedc645fdeef7061d4ebf9e42d651ddce67d01e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Tue, 14 Apr 2026 10:38:29 +0800 Subject: [PATCH 1/4] feat: support disabled as array for range slider - Add support for disabled={[boolean, boolean, ...]} to disable specific handles - Disabled handles cannot be dragged or moved via keyboard - Non-disabled handles cannot cross disabled handles - Clicking slider moves nearest non-disabled handle - In editable mode, disabled handles cannot be deleted - Track draggable is disabled when any handle is disabled - Backward compatible with boolean disabled Co-Authored-By: Claude Opus 4.6 --- assets/index.less | 14 +++ docs/demo/disabled-handle.md | 9 ++ docs/examples/disabled-handle.tsx | 85 +++++++++++++++++ src/Handles/Handle.tsx | 19 ++-- src/Slider.tsx | 60 +++++++++++- src/Tracks/index.tsx | 14 ++- src/context.ts | 1 + src/hooks/useDrag.ts | 9 ++ src/hooks/useOffset.ts | 25 +++++ tests/Range.test.tsx | 147 +++++++++++++++++++++++++++++- 10 files changed, 368 insertions(+), 15 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..c4bc0b6c6 --- /dev/null +++ b/docs/examples/disabled-handle.tsx @@ -0,0 +1,85 @@ +/* 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 BasicDisabledHandle = () => { + const [value, setValue] = useState([0, 30, 60, 100]); + const [disabled, setDisabled] = useState([true, false, false, true]); + + return ( +
+ +
+ {value.map((val, index) => ( + + ))} +
+
+ ); +}; + +const DisabledHandleAsBoundary = () => { + const [value, setValue] = useState([10, 50, 90]); + + 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. +

+
+ ); +}; + +const MultipleDisabledBoundaries = () => { + const [value, setValue] = useState([10, 30, 50, 70, 90]); + + return ( +
+ +

+ Handles at 10, 50, 90 are disabled. + Handle at 30 can only move between 10-50, handle at 70 can only move between 50-90. +

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

Basic Disabled Handle

+

Toggle checkboxes to disable/enable specific handles

+ +
+ +
+

Disabled Handle as Boundary

+ +
+ +
+

Multiple Disabled Boundaries

+ +
+
+); 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..dc8bf60c5 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,24 @@ 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 rawDisabled.every((d) => d); + }, [rawDisabled]); + + 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 +264,7 @@ const Slider = React.forwardRef>((prop markList, allowCross, mergedPush, + isHandleDisabled, ); // ============================ Values ============================ @@ -321,7 +339,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 +366,7 @@ const Slider = React.forwardRef>((prop offsetValues, rangeEditable, minCount, + isHandleDisabled, ); /** @@ -378,10 +397,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 +491,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 +594,7 @@ const Slider = React.forwardRef>((prop ariaValueTextFormatterForHandle, styles: styles || {}, classNames: classNames || {}, + isHandleDisabled, }), [ mergedMin, @@ -565,6 +614,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..c1c5f5eb7 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, @@ -91,6 +92,10 @@ function useDrag( (valueIndex: number, offsetPercent: number, deleteMark: boolean) => { if (valueIndex === -1) { // >>>> Dragging on the track + if (isHandleDisabled && originValues.some((_, index) => isHandleDisabled(index))) { + return; + } + const startValue = originValues[0]; const endValue = originValues[originValues.length - 1]; const maxStartOffset = min - startValue; @@ -126,6 +131,10 @@ function useDrag( // 如果是点击 track 触发的,需要传入变化后的初始值,而不能直接用 rawValues const initialValues = startValues || rawValues; + if (isHandleDisabled && isHandleDisabled(valueIndex)) { + return; + } + const originValue = initialValues[valueIndex]; setDraggingIndex(valueIndex); 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..d12026ae9 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,145 @@ 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(); + }); + }); }); From a7db3567012ec1641004fe2f9338c48d5436cd40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=AC=A2?= Date: Tue, 14 Apr 2026 10:58:53 +0800 Subject: [PATCH 2/4] fix: fix TypeScript type errors in disabled-handle example Co-Authored-By: Claude Opus 4.6 --- docs/examples/disabled-handle.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/examples/disabled-handle.tsx b/docs/examples/disabled-handle.tsx index c4bc0b6c6..7bdca0af5 100644 --- a/docs/examples/disabled-handle.tsx +++ b/docs/examples/disabled-handle.tsx @@ -9,12 +9,12 @@ const style: React.CSSProperties = { }; const BasicDisabledHandle = () => { - const [value, setValue] = useState([0, 30, 60, 100]); + const [value, setValue] = useState([0, 30, 60, 100]); const [disabled, setDisabled] = useState([true, false, false, true]); return (
- + setValue(v as number[])} disabled={disabled} />
{value.map((val, index) => (