From e1cc1c62b0bb2986eeae57f6395b4c0fa6d3e12c Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Sun, 30 Aug 2026 17:37:02 +0300 Subject: [PATCH 1/2] feat: add hasFixedDirection to Slider for non-mirrored media controls --- packages/react-aria-components/src/Slider.tsx | 5 +- .../stories/Slider.stories.tsx | 33 +++++- .../react-aria-components/test/Slider.test.js | 28 +++++ packages/react-aria/src/slider/useSlider.ts | 4 +- .../react-aria/src/slider/useSliderThumb.ts | 4 +- .../react-aria/test/slider/useSlider.test.js | 62 ++++++++++ .../test/slider/useSliderThumb.test.js | 112 ++++++++++++++++++ .../src/slider/useSliderState.ts | 15 ++- 8 files changed, 258 insertions(+), 5 deletions(-) diff --git a/packages/react-aria-components/src/Slider.tsx b/packages/react-aria-components/src/Slider.tsx index 7129ae20c18..4babf02dba7 100644 --- a/packages/react-aria-components/src/Slider.tsx +++ b/packages/react-aria-components/src/Slider.tsx @@ -436,7 +436,10 @@ export const SliderFill = /*#__PURE__*/ (forwardRef as forwardRefType)(function } : { position: 'absolute', - insetInlineStart: `${startPercent}%`, + // The thumb is positioned with the physical `left` property, so the fill must also be + // physical when the slider does not mirror. `insetInlineStart` would still resolve to + // `right` in an RTL document and the two would end up on opposite sides of the track. + [state.hasFixedDirection ? 'left' : 'insetInlineStart']: `${startPercent}%`, width: `${sizePercent}%`, height: '100%' }, diff --git a/packages/react-aria-components/stories/Slider.stories.tsx b/packages/react-aria-components/stories/Slider.stories.tsx index c8f77cc2f02..c5deee1fa70 100644 --- a/packages/react-aria-components/stories/Slider.stories.tsx +++ b/packages/react-aria-components/stories/Slider.stories.tsx @@ -10,11 +10,12 @@ * governing permissions and limitations under the License. */ +import {I18nProvider} from 'react-aria/I18nProvider'; import {Label} from '../src/Label'; import {Meta, StoryFn} from '@storybook/react'; import React from 'react'; -import {Slider, SliderOutput, SliderThumb, SliderTrack} from '../src/Slider'; +import {Slider, SliderFill, SliderOutput, SliderThumb, SliderTrack} from '../src/Slider'; import styles from '../example/index.css'; import './styles.css'; @@ -119,3 +120,33 @@ const CustomThumb = ({index, children}: {index: number; children: React.ReactNod ); }; + +export const SliderFixedDirection: SliderStory = () => ( + // Media controls such as a playback progress bar are conventionally laid out left to right + // even in RTL locales. Compare the two sliders below in an RTL locale: the first mirrors with + // the locale, the second stays left to right. + +
+ +
+ + +
+ + + + +
+ +
+ + +
+ + + + +
+
+
+); diff --git a/packages/react-aria-components/test/Slider.test.js b/packages/react-aria-components/test/Slider.test.js index aa40ce9b5a0..428eb0f9a91 100644 --- a/packages/react-aria-components/test/Slider.test.js +++ b/packages/react-aria-components/test/Slider.test.js @@ -456,4 +456,32 @@ describe('Slider', () => { ); expect(fill).toHaveStyle({position: 'absolute', bottom: '50%', height: '30%', width: '100%'}); }); + + describe('hasFixedDirection', () => { + it('positions SliderFill with insetInlineStart by default', () => { + let {getByRole} = render(); + let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + expect(fill).toHaveStyle({insetInlineStart: '0%', width: '30%'}); + expect(fill.style.left).toBe(''); + }); + + it('positions SliderFill with a physical left when hasFixedDirection is set', () => { + // insetInlineStart resolves to `right` in an RTL document, but the thumb is positioned + // with the physical `left`. A non-mirrored slider must use `left` for both so they stay + // on the same side of the track. + let {getByRole} = render(); + let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + expect(fill).toHaveStyle({left: '0%', width: '30%'}); + expect(fill.style.insetInlineStart).toBe(''); + }); + + it('does not affect vertical SliderFill', () => { + let {getByRole} = render( + + ); + let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + expect(fill).toHaveStyle({bottom: '0%', height: '30%'}); + expect(fill.style.left).toBe(''); + }); + }); }); diff --git a/packages/react-aria/src/slider/useSlider.ts b/packages/react-aria/src/slider/useSlider.ts index 49fc9427176..88bb7e25c6c 100644 --- a/packages/react-aria/src/slider/useSlider.ts +++ b/packages/react-aria/src/slider/useSlider.ts @@ -67,7 +67,9 @@ export function useSlider( 'aria-details': props['aria-details'] }); - let {direction} = useLocale(); + // Sliders that represent media playback rather than reading order are not mirrored in RTL locales. + let {direction: localeDirection} = useLocale(); + let direction = state.hasFixedDirection ? 'ltr' : localeDirection; let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); diff --git a/packages/react-aria/src/slider/useSliderThumb.ts b/packages/react-aria/src/slider/useSliderThumb.ts index 100b713eb18..d4eeb27b995 100644 --- a/packages/react-aria/src/slider/useSliderThumb.ts +++ b/packages/react-aria/src/slider/useSliderThumb.ts @@ -113,7 +113,9 @@ export function useSliderThumb(opts: AriaSliderThumbOptions, state: SliderState) let isDisabled = opts.isDisabled || state.isDisabled; let isVertical = orientation === 'vertical'; - let {direction} = useLocale(); + // Sliders that represent media playback rather than reading order are not mirrored in RTL locales. + let {direction: localeDirection} = useLocale(); + let direction = state.hasFixedDirection ? 'ltr' : localeDirection; let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); let data = sliderData.get(state)!; diff --git a/packages/react-aria/test/slider/useSlider.test.js b/packages/react-aria/test/slider/useSlider.test.js index 22b4a8519f6..6586b4afd11 100644 --- a/packages/react-aria/test/slider/useSlider.test.js +++ b/packages/react-aria/test/slider/useSlider.test.js @@ -6,6 +6,7 @@ import { renderHook, screen } from '@react-spectrum/test-utils-internal'; +import {I18nProvider} from '../../src/i18n/I18nProvider'; import * as React from 'react'; import {useRef} from 'react'; import {useSlider} from '../../src/slider/useSlider'; @@ -423,4 +424,65 @@ describe('useSlider', () => { expect(stateRef.current.values).toEqual([10, 80]); }); }); + + describe('hasFixedDirection', () => { + let widthStub; + beforeAll(() => { + widthStub = jest + .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(() => ({top: 0, left: 0, width: 100, height: 100})); + }); + afterAll(() => { + widthStub.mockReset(); + }); + + installMouseEvent(); + + let stateRef = React.createRef(); + + function Example(props) { + let trackRef = useRef(null); + let state = useSliderState({...props, numberFormatter}); + stateRef.current = state; + let {trackProps} = useSlider(props, state, trackRef); + return
; + } + + function clickTrackAt(clientX) { + let track = screen.getByTestId('track'); + fireEvent.mouseDown(track, {clientX, pageX: clientX}); + fireEvent.mouseUp(track, {clientX, pageX: clientX}); + } + + // 25 is used rather than 50 so that the mirrored and non-mirrored results differ. + it('mirrors a track click in an RTL locale by default', () => { + render( + + + + ); + clickTrackAt(25); + expect(stateRef.current.values).toEqual([75]); + }); + + it('does not mirror a track click in an RTL locale when hasFixedDirection is set', () => { + render( + + + + ); + clickTrackAt(25); + expect(stateRef.current.values).toEqual([25]); + }); + + it('does not change track clicks in an LTR locale', () => { + render( + + + + ); + clickTrackAt(25); + expect(stateRef.current.values).toEqual([25]); + }); + }); }); diff --git a/packages/react-aria/test/slider/useSliderThumb.test.js b/packages/react-aria/test/slider/useSliderThumb.test.js index e65a4ae684f..bb858c50ce3 100644 --- a/packages/react-aria/test/slider/useSliderThumb.test.js +++ b/packages/react-aria/test/slider/useSliderThumb.test.js @@ -7,6 +7,7 @@ import { renderHook, screen } from '@react-spectrum/test-utils-internal'; +import {I18nProvider} from '../../src/i18n/I18nProvider'; import * as React from 'react'; import {useRef} from 'react'; import userEvent from '@testing-library/user-event'; @@ -642,4 +643,115 @@ describe('useSliderThumb', () => { }); }); }); + + describe('hasFixedDirection', () => { + let widthStub; + beforeAll(() => { + widthStub = jest + .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(() => ({top: 0, left: 0, width: 100, height: 100})); + }); + afterAll(() => { + widthStub.mockReset(); + }); + + installMouseEvent(); + + let stateRef = React.createRef(); + + function Example(props) { + let trackRef = useRef(null); + let inputRef = useRef(null); + let state = useSliderState({...props, numberFormatter}); + stateRef.current = state; + let {trackProps} = useSlider(props, state, trackRef); + let {inputProps, thumbProps} = useSliderThumb( + {...props, 'aria-label': 'Value', index: 0, trackRef, inputRef}, + state + ); + return ( +
+
+ +
+
+ ); + } + + function renderSlider(locale, props) { + return render( + + + + ); + } + + // A value of 25 is used rather than 50 so the mirrored position (75%) and the + // non-mirrored position (25%) are distinguishable. + describe('thumb position', () => { + it('mirrors in an RTL locale by default', () => { + renderSlider('ar-AE'); + expect(screen.getByTestId('thumb')).toHaveStyle({left: '75%'}); + }); + + it('does not mirror in an RTL locale when hasFixedDirection is set', () => { + renderSlider('ar-AE', {hasFixedDirection: true}); + expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'}); + }); + + it('is unaffected in an LTR locale', () => { + renderSlider('en-US', {hasFixedDirection: true}); + expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'}); + }); + + it('still mirrors vertically regardless of hasFixedDirection', () => { + renderSlider('ar-AE', {hasFixedDirection: true, orientation: 'vertical'}); + // Vertical sliders always run bottom to top, which hasFixedDirection must not change. + expect(screen.getByTestId('thumb')).toHaveStyle({top: '75%'}); + }); + }); + + describe('keyboard', () => { + it('reverses arrow keys in an RTL locale by default', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + renderSlider('ar-AE'); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(stateRef.current.values).toEqual([24]); + await user.keyboard('{ArrowLeft}'); + expect(stateRef.current.values).toEqual([25]); + }); + + it('does not reverse arrow keys in an RTL locale when hasFixedDirection is set', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + renderSlider('ar-AE', {hasFixedDirection: true}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(stateRef.current.values).toEqual([26]); + await user.keyboard('{ArrowLeft}'); + expect(stateRef.current.values).toEqual([25]); + }); + }); + + describe('dragging', () => { + it('reverses drag in an RTL locale by default', () => { + renderSlider('ar-AE'); + let thumb = screen.getByTestId('thumb'); + fireEvent.mouseDown(thumb, {clientX: 25, pageX: 25}); + fireEvent.mouseMove(thumb, {clientX: 35, pageX: 35}); + fireEvent.mouseUp(thumb, {clientX: 35, pageX: 35}); + // Moving right decreases the value when mirrored. + expect(stateRef.current.values).toEqual([15]); + }); + + it('does not reverse drag in an RTL locale when hasFixedDirection is set', () => { + renderSlider('ar-AE', {hasFixedDirection: true}); + let thumb = screen.getByTestId('thumb'); + fireEvent.mouseDown(thumb, {clientX: 25, pageX: 25}); + fireEvent.mouseMove(thumb, {clientX: 35, pageX: 35}); + fireEvent.mouseUp(thumb, {clientX: 35, pageX: 35}); + expect(stateRef.current.values).toEqual([35]); + }); + }); + }); }); diff --git a/packages/react-stately/src/slider/useSliderState.ts b/packages/react-stately/src/slider/useSliderState.ts index ccc1d755665..b53c42fe669 100644 --- a/packages/react-stately/src/slider/useSliderState.ts +++ b/packages/react-stately/src/slider/useSliderState.ts @@ -24,6 +24,14 @@ export interface SliderProps * @default 'horizontal' */ orientation?: Orientation; + /** + * Whether the slider's layout direction stays left-to-right in right-to-left locales. + * Controls that represent media playback rather than reading order, such as an audio + * progress bar, are conventionally not mirrored. Has no effect on vertical sliders. + * + * @default false + */ + hasFixedDirection?: boolean; /** Whether the whole Slider is disabled. */ isDisabled?: boolean; /** Fired when the slider stops moving, due to being let go. */ @@ -194,6 +202,9 @@ export interface SliderState { /** The orientation of the slider. */ readonly orientation: Orientation; + /** Whether the slider's layout direction stays left-to-right in right-to-left locales. */ + readonly hasFixedDirection: boolean; + /** Whether the slider is disabled. */ readonly isDisabled: boolean; } @@ -223,7 +234,8 @@ export function useSliderState( maxValue = DEFAULT_MAX_VALUE, numberFormatter: formatter, step = DEFAULT_STEP_VALUE, - orientation = 'horizontal' + orientation = 'horizontal', + hasFixedDirection = false } = props; // Page step should be at least equal to step and always a multiple of the step. @@ -403,6 +415,7 @@ export function useSliderState( step, pageSize, orientation, + hasFixedDirection, isDisabled }; } From 3db72f54be143f6452095642da005c7117f3d260 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Sun, 30 Aug 2026 17:52:46 +0300 Subject: [PATCH 2/2] test: release the pointer in stacked-thumb track tests pointerDown installs mouseup/touchend/pointerup listeners on window. The two stacked-thumb tests never released the pointer, so on React 16 and 17 the listeners outlived the unmounted component and fired during a later test. --- packages/react-aria/test/slider/useSlider.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-aria/test/slider/useSlider.test.js b/packages/react-aria/test/slider/useSlider.test.js index 6586b4afd11..5891191a01a 100644 --- a/packages/react-aria/test/slider/useSlider.test.js +++ b/packages/react-aria/test/slider/useSlider.test.js @@ -316,6 +316,9 @@ describe('useSlider', () => { expect(onChangeSpy).toHaveBeenLastCalledWith([20, 40]); expect(onChangeEndSpy).not.toHaveBeenCalled(); expect(stateRef.current.values).toEqual([20, 40]); + + // Release the pointer so the global listeners installed on pointer down are removed. + fireEvent.pointerUp(track, {pageX: 20, clientX: 20}); }); it('should allow you to set value of after thumbs when thumbs stacked', () => { @@ -336,6 +339,9 @@ describe('useSlider', () => { expect(onChangeSpy).toHaveBeenLastCalledWith([40, 60]); expect(onChangeEndSpy).not.toHaveBeenCalled(); expect(stateRef.current.values).toEqual([40, 60]); + + // Release the pointer so the global listeners installed on pointer down are removed. + fireEvent.pointerUp(track, {pageX: 60, clientX: 60}); }); it('should allow you to set value of before thumbs when many thumbs and stacked', () => {