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..5891191a01a 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';
@@ -315,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', () => {
@@ -335,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', () => {
@@ -423,4 +430,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
};
}