From 72074f4210db7174cb29e3382e210ca96c74d830 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Tue, 8 Sep 2026 02:35:32 +0200 Subject: [PATCH 1/3] feat: add direction to Slider for tracks that don't follow the locale A horizontal slider mirrors with the UI locale, which is right when the track represents reading order. It is wrong when the direction comes from the content instead: a media playback bar stays left to right in an RTL UI, and a track over right-to-left content such as a manga page stays right to left in an LTR UI. `direction?: 'ltr' | 'rtl'` pins the track. Omitted, the slider mirrors the locale exactly as before. Direction is read in four places and they mirror by two mechanisms. `useSlider` (track click, track drag) and `useSliderThumb` (arrow keys, drag, thumb `left`) invert in JS and now resolve `state.direction ?? localeDirection`, so both pinned directions fall out of the existing `direction === 'rtl'` checks. `SliderFill` mirrors in CSS via `insetInlineStart`, which resolves against the document and is therefore the wrong edge for a pinned track; it now anchors physically, `left` for a pinned ltr track and `right` for a pinned rtl one, and keeps `insetInlineStart` untouched when nothing is pinned. Vertical is unaffected. The prop lives on `SliderState` rather than being threaded through both hooks, following `orientation`, which means S2 picks it up through `SliderBaseProps`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- packages/react-aria-components/src/Slider.tsx | 10 +- .../stories/Slider.stories.tsx | 60 ++++++- .../react-aria-components/test/Slider.test.js | 50 ++++++ packages/react-aria/src/slider/useSlider.ts | 5 +- .../react-aria/src/slider/useSliderThumb.ts | 5 +- .../react-aria/test/slider/useSlider.test.js | 68 ++++++++ .../test/slider/useSliderThumb.test.js | 161 ++++++++++++++++++ .../src/slider/useSliderState.ts | 28 ++- 8 files changed, 381 insertions(+), 6 deletions(-) diff --git a/packages/react-aria-components/src/Slider.tsx b/packages/react-aria-components/src/Slider.tsx index 7129ae20c18..c631fc6ef7a 100644 --- a/packages/react-aria-components/src/Slider.tsx +++ b/packages/react-aria-components/src/Slider.tsx @@ -423,6 +423,14 @@ export const SliderFill = /*#__PURE__*/ (forwardRef as forwardRefType)(function let endPercent = Math.max(start, end); let sizePercent = Math.max(0, endPercent - startPercent); + // The thumb is positioned with the physical `left` property, so a track that pins its own + // direction must anchor the fill physically too. `insetInlineStart` resolves against the + // document, which for a pinned track is the wrong edge and would put the fill and the thumb + // on opposite sides. When no direction is pinned the track follows the document, and + // `insetInlineStart` is exactly that. + let fillInset = + state.direction == null ? 'insetInlineStart' : state.direction === 'rtl' ? 'right' : 'left'; + let renderProps = useRenderProps({ ...props, defaultClassName: 'react-aria-SliderFill', @@ -436,7 +444,7 @@ export const SliderFill = /*#__PURE__*/ (forwardRef as forwardRefType)(function } : { position: 'absolute', - insetInlineStart: `${startPercent}%`, + [fillInset]: `${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..d8fc6ce8f90 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,60 @@ const CustomThumb = ({index, children}: {index: number; children: React.ReactNod ); }; + +export const SliderPinnedDirection: SliderStory = () => ( + // A slider whose direction comes from its content rather than the reading order pins `direction`. + // Left: an RTL locale, where a playback bar stays left to right. Right: an LTR locale, where a + // manga page position bar runs right to left. Each is shown next to an unpinned control that + // follows the locale. +
+ +
+ +
+ + +
+ + + + +
+ +
+ + +
+ + + + +
+
+
+ +
+ +
+ + +
+ + + + +
+ +
+ + +
+ + + + +
+
+
+
+); diff --git a/packages/react-aria-components/test/Slider.test.js b/packages/react-aria-components/test/Slider.test.js index aa40ce9b5a0..674bf6f5ee4 100644 --- a/packages/react-aria-components/test/Slider.test.js +++ b/packages/react-aria-components/test/Slider.test.js @@ -456,4 +456,54 @@ describe('Slider', () => { ); expect(fill).toHaveStyle({position: 'absolute', bottom: '50%', height: '30%', width: '100%'}); }); + + describe('direction', () => { + 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(''); + expect(fill.style.right).toBe(''); + }); + + // insetInlineStart resolves against the document, but the thumb is positioned with the + // physical `left`. A track that pins its own direction must anchor the fill physically so + // the fill and the thumb stay on the same side. + it('positions SliderFill with a physical left when direction is ltr', () => { + let {getByRole} = render(); + let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + expect(fill).toHaveStyle({left: '0%', width: '30%'}); + expect(fill.style.insetInlineStart).toBe(''); + expect(fill.style.right).toBe(''); + }); + + // The case a boolean could not express: content that reads right to left inside an LTR UI. + it('positions SliderFill with a physical right when direction is rtl', () => { + let {getByRole} = render(); + let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + expect(fill).toHaveStyle({right: '0%', width: '30%'}); + expect(fill.style.insetInlineStart).toBe(''); + expect(fill.style.left).toBe(''); + }); + + it('does not affect vertical SliderFill when direction is ltr', () => { + let {getByRole} = render( + + ); + let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + expect(fill).toHaveStyle({bottom: '0%', height: '30%'}); + expect(fill.style.left).toBe(''); + expect(fill.style.right).toBe(''); + }); + + it('does not affect vertical SliderFill when direction is rtl', () => { + let {getByRole} = render( + + ); + let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + expect(fill).toHaveStyle({bottom: '0%', height: '30%'}); + expect(fill.style.left).toBe(''); + expect(fill.style.right).toBe(''); + }); + }); }); diff --git a/packages/react-aria/src/slider/useSlider.ts b/packages/react-aria/src/slider/useSlider.ts index 49fc9427176..2c4c18ba0a5 100644 --- a/packages/react-aria/src/slider/useSlider.ts +++ b/packages/react-aria/src/slider/useSlider.ts @@ -67,7 +67,10 @@ export function useSlider( 'aria-details': props['aria-details'] }); - let {direction} = useLocale(); + // A slider whose direction comes from its content rather than the reading order pins its own + // direction; otherwise the track mirrors the locale. + let {direction: localeDirection} = useLocale(); + let direction = state.direction ?? 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..93383e5c11f 100644 --- a/packages/react-aria/src/slider/useSliderThumb.ts +++ b/packages/react-aria/src/slider/useSliderThumb.ts @@ -113,7 +113,10 @@ export function useSliderThumb(opts: AriaSliderThumbOptions, state: SliderState) let isDisabled = opts.isDisabled || state.isDisabled; let isVertical = orientation === 'vertical'; - let {direction} = useLocale(); + // A slider whose direction comes from its content rather than the reading order pins its own + // direction; otherwise the track mirrors the locale. + let {direction: localeDirection} = useLocale(); + let direction = state.direction ?? 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..b2416e9f865 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,71 @@ describe('useSlider', () => { expect(stateRef.current.values).toEqual([10, 80]); }); }); + + describe('direction', () => { + 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(locale, props, clientX) { + render( + + + + ); + 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', () => { + clickTrackAt('ar-AE', {}, 25); + expect(stateRef.current.values).toEqual([75]); + }); + + it('does not mirror a track click in an LTR locale by default', () => { + clickTrackAt('en-US', {}, 25); + expect(stateRef.current.values).toEqual([25]); + }); + + it('does not mirror a track click in an RTL locale when direction is ltr', () => { + clickTrackAt('ar-AE', {direction: 'ltr'}, 25); + expect(stateRef.current.values).toEqual([25]); + }); + + // The case a boolean could not express: content that reads right to left inside an LTR UI. + it('mirrors a track click in an LTR locale when direction is rtl', () => { + clickTrackAt('en-US', {direction: 'rtl'}, 25); + expect(stateRef.current.values).toEqual([75]); + }); + + it('is a no-op when direction matches the locale', () => { + clickTrackAt('en-US', {direction: 'ltr'}, 25); + expect(stateRef.current.values).toEqual([25]); + }); + + it('is a no-op when direction matches an RTL locale', () => { + clickTrackAt('ar-AE', {direction: 'rtl'}, 25); + expect(stateRef.current.values).toEqual([75]); + }); + }); }); diff --git a/packages/react-aria/test/slider/useSliderThumb.test.js b/packages/react-aria/test/slider/useSliderThumb.test.js index e65a4ae684f..f63a87cdfd9 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,164 @@ describe('useSliderThumb', () => { }); }); }); + + describe('direction', () => { + 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 LTR locale by default', () => { + renderSlider('en-US'); + expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'}); + }); + + it('does not mirror in an RTL locale when direction is ltr', () => { + renderSlider('ar-AE', {direction: 'ltr'}); + expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'}); + }); + + // The case a boolean could not express: content that reads right to left inside an LTR UI. + it('mirrors in an LTR locale when direction is rtl', () => { + renderSlider('en-US', {direction: 'rtl'}); + expect(screen.getByTestId('thumb')).toHaveStyle({left: '75%'}); + }); + + it('is a no-op when direction matches the locale', () => { + renderSlider('en-US', {direction: 'ltr'}); + expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'}); + }); + + it('still runs bottom to top vertically when direction is ltr', () => { + renderSlider('ar-AE', {direction: 'ltr', orientation: 'vertical'}); + // Vertical sliders always run bottom to top, which direction must not change. + expect(screen.getByTestId('thumb')).toHaveStyle({top: '75%'}); + }); + + it('still runs bottom to top vertically when direction is rtl', () => { + renderSlider('en-US', {direction: 'rtl', orientation: 'vertical'}); + 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 direction is ltr', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + renderSlider('ar-AE', {direction: 'ltr'}); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(stateRef.current.values).toEqual([26]); + await user.keyboard('{ArrowLeft}'); + expect(stateRef.current.values).toEqual([25]); + }); + + it('reverses arrow keys in an LTR locale when direction is rtl', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + renderSlider('en-US', {direction: 'rtl'}); + 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 LTR locale by default', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + renderSlider('en-US'); + 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', () => { + function dragThumbRight() { + 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}); + } + + it('reverses drag in an RTL locale by default', () => { + renderSlider('ar-AE'); + dragThumbRight(); + // Moving right decreases the value when mirrored. + expect(stateRef.current.values).toEqual([15]); + }); + + it('does not reverse drag in an LTR locale by default', () => { + renderSlider('en-US'); + dragThumbRight(); + expect(stateRef.current.values).toEqual([35]); + }); + + it('does not reverse drag in an RTL locale when direction is ltr', () => { + renderSlider('ar-AE', {direction: 'ltr'}); + dragThumbRight(); + expect(stateRef.current.values).toEqual([35]); + }); + + it('reverses drag in an LTR locale when direction is rtl', () => { + renderSlider('en-US', {direction: 'rtl'}); + dragThumbRight(); + expect(stateRef.current.values).toEqual([15]); + }); + }); + }); }); diff --git a/packages/react-stately/src/slider/useSliderState.ts b/packages/react-stately/src/slider/useSliderState.ts index ccc1d755665..91257094cba 100644 --- a/packages/react-stately/src/slider/useSliderState.ts +++ b/packages/react-stately/src/slider/useSliderState.ts @@ -12,7 +12,13 @@ import {clamp, snapValueToStep} from '../utils/number'; -import {LabelableProps, Orientation, RangeInputBase, ValueBase} from '@react-types/shared'; +import { + Direction, + LabelableProps, + Orientation, + RangeInputBase, + ValueBase +} from '@react-types/shared'; import {useCallback, useMemo, useRef, useState} from 'react'; import {useControlledState} from '../utils/useControlledState'; @@ -24,6 +30,16 @@ export interface SliderProps * @default 'horizontal' */ orientation?: Orientation; + /** + * The layout direction of the slider track, pinned regardless of the UI locale. + * By default the track mirrors the locale, which is right for a slider that represents + * reading order. Controls whose direction comes from their content instead should set this: + * a media playback bar stays `'ltr'` in an RTL UI, and a manga page position bar stays + * `'rtl'` in an LTR UI. Has no effect on vertical sliders. + * + * @default the direction of the UI locale + */ + direction?: Direction; /** Whether the whole Slider is disabled. */ isDisabled?: boolean; /** Fired when the slider stops moving, due to being let go. */ @@ -194,6 +210,12 @@ export interface SliderState { /** The orientation of the slider. */ readonly orientation: Orientation; + /** + * The layout direction of the slider track, pinned regardless of the UI locale, + * or undefined when the track follows the locale. + */ + readonly direction: Direction | undefined; + /** Whether the slider is disabled. */ readonly isDisabled: boolean; } @@ -223,7 +245,8 @@ export function useSliderState( maxValue = DEFAULT_MAX_VALUE, numberFormatter: formatter, step = DEFAULT_STEP_VALUE, - orientation = 'horizontal' + orientation = 'horizontal', + direction } = props; // Page step should be at least equal to step and always a multiple of the step. @@ -403,6 +426,7 @@ export function useSliderState( step, pageSize, orientation, + direction, isDisabled }; } From c856427f08d534359866bb9d9c911bb720da4e78 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Tue, 8 Sep 2026 02:35:32 +0200 Subject: [PATCH 2/3] 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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011M5uTyCU4WcNTsPvGrErDo --- 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 b2416e9f865..ed9c362bb2b 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', () => { From f7bf04079b425cfad28b6580724278dea0e2afc9 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Wed, 21 Oct 2026 19:43:41 -0400 Subject: [PATCH 3/3] feat: add direction to I18nProvider as an override of the locale Replaces the Slider `direction` prop with the approach asked for in review: `I18nProvider` takes an optional `direction` that overrides the direction implied by the locale, and a control whose direction comes from its content rather than from the reading order is wrapped in one. That works for every direction-aware component instead of only the slider, and it nests, so a part of a subtree can be put back to the locale direction. The locale itself is untouched by the override, so formatters and translated strings inside an ltr-pinned Arabic subtree are still Arabic. `SliderFill` now anchors with a physical `left`/`right` taken from `useLocale()` rather than with `insetInlineStart`. The thumb has always been positioned with a physical `left` against that same direction, while `insetInlineStart` resolves against the document, so the two disagreed as soon as a provider overrode the direction and the fill and the thumb landed on opposite edges. With no override the resolved edge is unchanged, only the property name, which is why the three existing SliderFill assertions move from `insetInlineStart` to `left`. --- packages/react-aria-components/src/Slider.tsx | 14 ++-- .../stories/Slider.stories.tsx | 55 +++++++----- .../react-aria-components/test/Slider.test.js | 74 ++++++++++------- packages/react-aria/src/i18n/I18nProvider.tsx | 31 +++++-- packages/react-aria/src/slider/useSlider.ts | 5 +- .../react-aria/src/slider/useSliderThumb.ts | 5 +- .../react-aria/test/i18n/I18nProvider.test.js | 83 +++++++++++++++++++ .../react-aria/test/slider/useSlider.test.js | 26 +++--- .../test/slider/useSliderThumb.test.js | 40 ++++----- .../src/slider/useSliderState.ts | 28 +------ 10 files changed, 224 insertions(+), 137 deletions(-) create mode 100644 packages/react-aria/test/i18n/I18nProvider.test.js diff --git a/packages/react-aria-components/src/Slider.tsx b/packages/react-aria-components/src/Slider.tsx index c631fc6ef7a..0310c14680c 100644 --- a/packages/react-aria-components/src/Slider.tsx +++ b/packages/react-aria-components/src/Slider.tsx @@ -47,6 +47,7 @@ import React, { import {SliderState, useSliderState} from 'react-stately/useSliderState'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; +import {useLocale} from 'react-aria/I18nProvider'; import {useNumberFormatter} from 'react-aria/useNumberFormatter'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; @@ -423,13 +424,12 @@ export const SliderFill = /*#__PURE__*/ (forwardRef as forwardRefType)(function let endPercent = Math.max(start, end); let sizePercent = Math.max(0, endPercent - startPercent); - // The thumb is positioned with the physical `left` property, so a track that pins its own - // direction must anchor the fill physically too. `insetInlineStart` resolves against the - // document, which for a pinned track is the wrong edge and would put the fill and the thumb - // on opposite sides. When no direction is pinned the track follows the document, and - // `insetInlineStart` is exactly that. - let fillInset = - state.direction == null ? 'insetInlineStart' : state.direction === 'rtl' ? 'right' : 'left'; + // The thumb is positioned with the physical `left` property against the direction from + // `useLocale`, so the fill has to be anchored physically against the same direction. + // `insetInlineStart` resolves against the document instead, which disagrees whenever an + // `I18nProvider` overrides the direction, putting the fill and the thumb on opposite edges. + let {direction} = useLocale(); + let fillInset = direction === 'rtl' ? 'right' : 'left'; let renderProps = useRenderProps({ ...props, diff --git a/packages/react-aria-components/stories/Slider.stories.tsx b/packages/react-aria-components/stories/Slider.stories.tsx index d8fc6ce8f90..5503043988e 100644 --- a/packages/react-aria-components/stories/Slider.stories.tsx +++ b/packages/react-aria-components/stories/Slider.stories.tsx @@ -121,11 +121,12 @@ const CustomThumb = ({index, children}: {index: number; children: React.ReactNod ); }; -export const SliderPinnedDirection: SliderStory = () => ( - // A slider whose direction comes from its content rather than the reading order pins `direction`. - // Left: an RTL locale, where a playback bar stays left to right. Right: an LTR locale, where a - // manga page position bar runs right to left. Each is shown next to an unpinned control that - // follows the locale. +export const SliderOverriddenDirection: SliderStory = () => ( + // A control whose direction comes from its content rather than from the reading order is wrapped + // in an `I18nProvider` that overrides the direction. Left: an RTL locale, where a playback bar + // stays left to right. Right: an LTR locale, where a manga page position bar runs right to left. + // Each is shown next to a control that follows the locale. The wrapper also sets `dir`, so that + // CSS logical properties inside it resolve the same way the components do.
@@ -139,16 +140,20 @@ export const SliderPinnedDirection: SliderStory = () => ( - -
- - + +
+ +
+ + +
+ + + + +
- - - - - +
@@ -163,16 +168,20 @@ export const SliderPinnedDirection: SliderStory = () => (
- -
- - + +
+ +
+ + +
+ + + + +
- - - - - +
diff --git a/packages/react-aria-components/test/Slider.test.js b/packages/react-aria-components/test/Slider.test.js index 674bf6f5ee4..487d0f35fae 100644 --- a/packages/react-aria-components/test/Slider.test.js +++ b/packages/react-aria-components/test/Slider.test.js @@ -11,6 +11,7 @@ */ import {fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {I18nProvider} from 'react-aria/I18nProvider'; import {Label} from '../src/Label'; import React, {useState} from 'react'; import { @@ -405,7 +406,7 @@ describe('Slider', () => { expect(fill).toHaveAttribute('data-orientation', 'horizontal'); expect(fill).toHaveStyle({ position: 'absolute', - insetInlineStart: '0%', + left: '0%', width: '30%', height: '100%' }); @@ -420,7 +421,7 @@ describe('Slider', () => { expect(fill).toHaveAttribute('data-orientation', 'horizontal'); expect(fill).toHaveStyle({ position: 'absolute', - insetInlineStart: '30%', + left: '30%', width: '20%', height: '100%' }); @@ -428,7 +429,7 @@ describe('Slider', () => { rerender(); expect(fill).toHaveStyle({ position: 'absolute', - insetInlineStart: '50%', + left: '50%', width: '30%', height: '100%' }); @@ -458,49 +459,58 @@ describe('Slider', () => { }); describe('direction', () => { - 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(''); - expect(fill.style.right).toBe(''); - }); + function renderWithDirection(locale, direction, sliderProps) { + return render( + + + + ); + } - // insetInlineStart resolves against the document, but the thumb is positioned with the - // physical `left`. A track that pins its own direction must anchor the fill physically so - // the fill and the thumb stay on the same side. - it('positions SliderFill with a physical left when direction is ltr', () => { - let {getByRole} = render(); - let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + function parts(getByRole) { + let group = getByRole('group'); + return { + fill: group.querySelector('.react-aria-SliderFill'), + thumb: group.querySelector('.react-aria-SliderThumb') + }; + } + + // The fill is anchored with a physical property rather than `insetInlineStart`, because + // the thumb is positioned with a physical `left` and the two have to agree. A logical + // property would resolve against the document, which an I18nProvider override does not change. + it('anchors the fill and the thumb to the same edge in an LTR locale', () => { + let {fill, thumb} = parts(renderWithDirection('en-US', undefined).getByRole); expect(fill).toHaveStyle({left: '0%', width: '30%'}); expect(fill.style.insetInlineStart).toBe(''); expect(fill.style.right).toBe(''); + expect(thumb).toHaveStyle({left: '30%'}); }); - // The case a boolean could not express: content that reads right to left inside an LTR UI. - it('positions SliderFill with a physical right when direction is rtl', () => { - let {getByRole} = render(); - let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + it('anchors the fill and the thumb to the same edge in an RTL locale', () => { + let {fill, thumb} = parts(renderWithDirection('ar-AE', undefined).getByRole); expect(fill).toHaveStyle({right: '0%', width: '30%'}); expect(fill.style.insetInlineStart).toBe(''); expect(fill.style.left).toBe(''); + expect(thumb).toHaveStyle({left: '70%'}); }); - it('does not affect vertical SliderFill when direction is ltr', () => { - let {getByRole} = render( - - ); - let fill = getByRole('group').querySelector('.react-aria-SliderFill'); - expect(fill).toHaveStyle({bottom: '0%', height: '30%'}); - expect(fill.style.left).toBe(''); + it('follows an ltr override inside an RTL locale', () => { + let {fill, thumb} = parts(renderWithDirection('ar-AE', 'ltr').getByRole); + expect(fill).toHaveStyle({left: '0%', width: '30%'}); expect(fill.style.right).toBe(''); + expect(thumb).toHaveStyle({left: '30%'}); }); - it('does not affect vertical SliderFill when direction is rtl', () => { - let {getByRole} = render( - - ); - let fill = getByRole('group').querySelector('.react-aria-SliderFill'); + // The case a boolean could not express: content that reads right to left inside an LTR UI. + it('follows an rtl override inside an LTR locale', () => { + let {fill, thumb} = parts(renderWithDirection('en-US', 'rtl').getByRole); + expect(fill).toHaveStyle({right: '0%', width: '30%'}); + expect(fill.style.left).toBe(''); + expect(thumb).toHaveStyle({left: '70%'}); + }); + + it('leaves a vertical fill on the bottom edge whatever the direction', () => { + let {fill} = parts(renderWithDirection('ar-AE', 'ltr', {orientation: 'vertical'}).getByRole); expect(fill).toHaveStyle({bottom: '0%', height: '30%'}); expect(fill.style.left).toBe(''); expect(fill.style.right).toBe(''); diff --git a/packages/react-aria/src/i18n/I18nProvider.tsx b/packages/react-aria/src/i18n/I18nProvider.tsx index 61ea62a098f..272885602fb 100644 --- a/packages/react-aria/src/i18n/I18nProvider.tsx +++ b/packages/react-aria/src/i18n/I18nProvider.tsx @@ -27,6 +27,16 @@ export interface I18nProviderProps { children: ReactNode; /** The locale to apply to the children. */ locale?: string; + /** + * The writing direction to apply to the children, overriding the direction implied by the + * locale. Use this for content whose direction comes from the content itself rather than from + * the reading order of the UI, such as a media playback bar that stays `'ltr'` in an RTL UI. + * Set `dir` on an element inside the provider as well, so that CSS logical properties resolve + * the same way the components do. + * + * @default the direction implied by the locale + */ + direction?: Direction; } const I18nContext = React.createContext(null); @@ -39,13 +49,13 @@ interface I18nProviderWithLocaleProps extends I18nProviderProps { * Internal component that handles the case when locale is provided. */ function I18nProviderWithLocale(props: I18nProviderWithLocaleProps): JSX.Element { - let {locale, children} = props; + let {locale, direction, children} = props; let value: Locale = React.useMemo( () => ({ locale, - direction: isRTL(locale) ? 'rtl' : 'ltr' + direction: direction ?? (isRTL(locale) ? 'rtl' : 'ltr') }), - [locale] + [locale, direction] ); return {children}; @@ -53,31 +63,36 @@ function I18nProviderWithLocale(props: I18nProviderWithLocaleProps): JSX.Element interface I18nProviderWithDefaultLocaleProps { children: ReactNode; + direction?: Direction; } /** * Internal component that handles the case when no locale is provided. */ function I18nProviderWithDefaultLocale(props: I18nProviderWithDefaultLocaleProps): JSX.Element { - let {children} = props; + let {direction, children} = props; let defaultLocale = useDefaultLocale(); + let value: Locale = React.useMemo( + () => (direction ? {locale: defaultLocale.locale, direction} : defaultLocale), + [defaultLocale, direction] + ); - return {children}; + return {children}; } /** * Provides the locale for the application to all child components. */ export function I18nProvider(props: I18nProviderProps): JSX.Element { - let {locale, children} = props; + let {locale, direction, children} = props; // Conditionally render different components to avoid calling useDefaultLocale. // This is necessary because useDefaultLocale triggers a re-render. if (locale) { - return ; + return ; } - return ; + return ; } /** diff --git a/packages/react-aria/src/slider/useSlider.ts b/packages/react-aria/src/slider/useSlider.ts index 2c4c18ba0a5..49fc9427176 100644 --- a/packages/react-aria/src/slider/useSlider.ts +++ b/packages/react-aria/src/slider/useSlider.ts @@ -67,10 +67,7 @@ export function useSlider( 'aria-details': props['aria-details'] }); - // A slider whose direction comes from its content rather than the reading order pins its own - // direction; otherwise the track mirrors the locale. - let {direction: localeDirection} = useLocale(); - let direction = state.direction ?? localeDirection; + let {direction} = useLocale(); let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); diff --git a/packages/react-aria/src/slider/useSliderThumb.ts b/packages/react-aria/src/slider/useSliderThumb.ts index 93383e5c11f..100b713eb18 100644 --- a/packages/react-aria/src/slider/useSliderThumb.ts +++ b/packages/react-aria/src/slider/useSliderThumb.ts @@ -113,10 +113,7 @@ export function useSliderThumb(opts: AriaSliderThumbOptions, state: SliderState) let isDisabled = opts.isDisabled || state.isDisabled; let isVertical = orientation === 'vertical'; - // A slider whose direction comes from its content rather than the reading order pins its own - // direction; otherwise the track mirrors the locale. - let {direction: localeDirection} = useLocale(); - let direction = state.direction ?? localeDirection; + let {direction} = useLocale(); let {addGlobalListener, removeGlobalListener} = useGlobalListeners(); let data = sliderData.get(state)!; diff --git a/packages/react-aria/test/i18n/I18nProvider.test.js b/packages/react-aria/test/i18n/I18nProvider.test.js new file mode 100644 index 00000000000..6182cc1e4a2 --- /dev/null +++ b/packages/react-aria/test/i18n/I18nProvider.test.js @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {I18nProvider, useLocale} from '../../src/i18n/I18nProvider'; +import React from 'react'; +import {render, screen} from '@react-spectrum/test-utils-internal'; + +function TestComponent() { + let {locale, direction} = useLocale(); + return ( +
+
{locale}
+
{direction}
+
+ ); +} + +function renderProvider(props) { + render( + + + + ); + return { + locale: screen.getByTestId('locale').textContent, + direction: screen.getByTestId('direction').textContent + }; +} + +describe('I18nProvider direction', () => { + it('takes rtl from an RTL locale when no direction is given', () => { + expect(renderProvider({locale: 'ar-AE'})).toEqual({locale: 'ar-AE', direction: 'rtl'}); + }); + + it('takes ltr from an LTR locale when no direction is given', () => { + expect(renderProvider({locale: 'en-US'})).toEqual({locale: 'en-US', direction: 'ltr'}); + }); + + it('overrides an RTL locale with ltr', () => { + expect(renderProvider({locale: 'ar-AE', direction: 'ltr'})).toEqual({ + locale: 'ar-AE', + direction: 'ltr' + }); + }); + + it('overrides an LTR locale with rtl', () => { + expect(renderProvider({locale: 'en-US', direction: 'rtl'})).toEqual({ + locale: 'en-US', + direction: 'rtl' + }); + }); + + it('leaves the locale itself alone', () => { + // The override is about layout only. Formatters and translated strings still follow + // the locale, so a date inside an ltr-pinned Arabic subtree is still formatted in Arabic. + expect(renderProvider({locale: 'ar-AE', direction: 'ltr'}).locale).toBe('ar-AE'); + }); + + it('overrides the default locale without being given one', () => { + let {direction} = renderProvider({direction: 'rtl'}); + expect(direction).toBe('rtl'); + }); + + it('restores the locale direction in a nested provider', () => { + render( + + + + + + ); + expect(screen.getByTestId('direction')).toHaveTextContent('rtl'); + }); +}); diff --git a/packages/react-aria/test/slider/useSlider.test.js b/packages/react-aria/test/slider/useSlider.test.js index ed9c362bb2b..3e79f7c9564 100644 --- a/packages/react-aria/test/slider/useSlider.test.js +++ b/packages/react-aria/test/slider/useSlider.test.js @@ -454,10 +454,10 @@ describe('useSlider', () => { return
; } - function clickTrackAt(locale, props, clientX) { + function clickTrackAt(locale, direction, clientX) { render( - - + + ); let track = screen.getByTestId('track'); @@ -467,33 +467,33 @@ describe('useSlider', () => { // 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', () => { - clickTrackAt('ar-AE', {}, 25); + clickTrackAt('ar-AE', undefined, 25); expect(stateRef.current.values).toEqual([75]); }); it('does not mirror a track click in an LTR locale by default', () => { - clickTrackAt('en-US', {}, 25); + clickTrackAt('en-US', undefined, 25); expect(stateRef.current.values).toEqual([25]); }); - it('does not mirror a track click in an RTL locale when direction is ltr', () => { - clickTrackAt('ar-AE', {direction: 'ltr'}, 25); + it('does not mirror a track click in an RTL locale under an ltr I18nProvider', () => { + clickTrackAt('ar-AE', 'ltr', 25); expect(stateRef.current.values).toEqual([25]); }); // The case a boolean could not express: content that reads right to left inside an LTR UI. - it('mirrors a track click in an LTR locale when direction is rtl', () => { - clickTrackAt('en-US', {direction: 'rtl'}, 25); + it('mirrors a track click in an LTR locale under an rtl I18nProvider', () => { + clickTrackAt('en-US', 'rtl', 25); expect(stateRef.current.values).toEqual([75]); }); - it('is a no-op when direction matches the locale', () => { - clickTrackAt('en-US', {direction: 'ltr'}, 25); + it('is a no-op when the override matches the locale', () => { + clickTrackAt('en-US', 'ltr', 25); expect(stateRef.current.values).toEqual([25]); }); - it('is a no-op when direction matches an RTL locale', () => { - clickTrackAt('ar-AE', {direction: 'rtl'}, 25); + it('is a no-op when the override matches an RTL locale', () => { + clickTrackAt('ar-AE', 'rtl', 25); expect(stateRef.current.values).toEqual([75]); }); }); diff --git a/packages/react-aria/test/slider/useSliderThumb.test.js b/packages/react-aria/test/slider/useSliderThumb.test.js index f63a87cdfd9..572f38e9ac3 100644 --- a/packages/react-aria/test/slider/useSliderThumb.test.js +++ b/packages/react-aria/test/slider/useSliderThumb.test.js @@ -678,9 +678,9 @@ describe('useSliderThumb', () => { ); } - function renderSlider(locale, props) { + function renderSlider(locale, direction, props) { return render( - + ); @@ -699,30 +699,30 @@ describe('useSliderThumb', () => { expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'}); }); - it('does not mirror in an RTL locale when direction is ltr', () => { - renderSlider('ar-AE', {direction: 'ltr'}); + it('does not mirror in an RTL locale under an ltr I18nProvider', () => { + renderSlider('ar-AE', 'ltr'); expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'}); }); // The case a boolean could not express: content that reads right to left inside an LTR UI. - it('mirrors in an LTR locale when direction is rtl', () => { - renderSlider('en-US', {direction: 'rtl'}); + it('mirrors in an LTR locale under an rtl I18nProvider', () => { + renderSlider('en-US', 'rtl'); expect(screen.getByTestId('thumb')).toHaveStyle({left: '75%'}); }); - it('is a no-op when direction matches the locale', () => { - renderSlider('en-US', {direction: 'ltr'}); + it('is a no-op when the override matches the locale', () => { + renderSlider('en-US', 'ltr'); expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'}); }); - it('still runs bottom to top vertically when direction is ltr', () => { - renderSlider('ar-AE', {direction: 'ltr', orientation: 'vertical'}); + it('still runs bottom to top vertically under an ltr I18nProvider', () => { + renderSlider('ar-AE', 'ltr', {orientation: 'vertical'}); // Vertical sliders always run bottom to top, which direction must not change. expect(screen.getByTestId('thumb')).toHaveStyle({top: '75%'}); }); - it('still runs bottom to top vertically when direction is rtl', () => { - renderSlider('en-US', {direction: 'rtl', orientation: 'vertical'}); + it('still runs bottom to top vertically under an rtl I18nProvider', () => { + renderSlider('en-US', 'rtl', {orientation: 'vertical'}); expect(screen.getByTestId('thumb')).toHaveStyle({top: '75%'}); }); }); @@ -738,9 +738,9 @@ describe('useSliderThumb', () => { expect(stateRef.current.values).toEqual([25]); }); - it('does not reverse arrow keys in an RTL locale when direction is ltr', async () => { + it('does not reverse arrow keys in an RTL locale under an ltr I18nProvider', async () => { let user = userEvent.setup({delay: null, pointerMap}); - renderSlider('ar-AE', {direction: 'ltr'}); + renderSlider('ar-AE', 'ltr'); await user.tab(); await user.keyboard('{ArrowRight}'); expect(stateRef.current.values).toEqual([26]); @@ -748,9 +748,9 @@ describe('useSliderThumb', () => { expect(stateRef.current.values).toEqual([25]); }); - it('reverses arrow keys in an LTR locale when direction is rtl', async () => { + it('reverses arrow keys in an LTR locale under an rtl I18nProvider', async () => { let user = userEvent.setup({delay: null, pointerMap}); - renderSlider('en-US', {direction: 'rtl'}); + renderSlider('en-US', 'rtl'); await user.tab(); await user.keyboard('{ArrowRight}'); expect(stateRef.current.values).toEqual([24]); @@ -790,14 +790,14 @@ describe('useSliderThumb', () => { expect(stateRef.current.values).toEqual([35]); }); - it('does not reverse drag in an RTL locale when direction is ltr', () => { - renderSlider('ar-AE', {direction: 'ltr'}); + it('does not reverse drag in an RTL locale under an ltr I18nProvider', () => { + renderSlider('ar-AE', 'ltr'); dragThumbRight(); expect(stateRef.current.values).toEqual([35]); }); - it('reverses drag in an LTR locale when direction is rtl', () => { - renderSlider('en-US', {direction: 'rtl'}); + it('reverses drag in an LTR locale under an rtl I18nProvider', () => { + renderSlider('en-US', 'rtl'); dragThumbRight(); expect(stateRef.current.values).toEqual([15]); }); diff --git a/packages/react-stately/src/slider/useSliderState.ts b/packages/react-stately/src/slider/useSliderState.ts index 91257094cba..ccc1d755665 100644 --- a/packages/react-stately/src/slider/useSliderState.ts +++ b/packages/react-stately/src/slider/useSliderState.ts @@ -12,13 +12,7 @@ import {clamp, snapValueToStep} from '../utils/number'; -import { - Direction, - LabelableProps, - Orientation, - RangeInputBase, - ValueBase -} from '@react-types/shared'; +import {LabelableProps, Orientation, RangeInputBase, ValueBase} from '@react-types/shared'; import {useCallback, useMemo, useRef, useState} from 'react'; import {useControlledState} from '../utils/useControlledState'; @@ -30,16 +24,6 @@ export interface SliderProps * @default 'horizontal' */ orientation?: Orientation; - /** - * The layout direction of the slider track, pinned regardless of the UI locale. - * By default the track mirrors the locale, which is right for a slider that represents - * reading order. Controls whose direction comes from their content instead should set this: - * a media playback bar stays `'ltr'` in an RTL UI, and a manga page position bar stays - * `'rtl'` in an LTR UI. Has no effect on vertical sliders. - * - * @default the direction of the UI locale - */ - direction?: Direction; /** Whether the whole Slider is disabled. */ isDisabled?: boolean; /** Fired when the slider stops moving, due to being let go. */ @@ -210,12 +194,6 @@ export interface SliderState { /** The orientation of the slider. */ readonly orientation: Orientation; - /** - * The layout direction of the slider track, pinned regardless of the UI locale, - * or undefined when the track follows the locale. - */ - readonly direction: Direction | undefined; - /** Whether the slider is disabled. */ readonly isDisabled: boolean; } @@ -245,8 +223,7 @@ export function useSliderState( maxValue = DEFAULT_MAX_VALUE, numberFormatter: formatter, step = DEFAULT_STEP_VALUE, - orientation = 'horizontal', - direction + orientation = 'horizontal' } = props; // Page step should be at least equal to step and always a multiple of the step. @@ -426,7 +403,6 @@ export function useSliderState( step, pageSize, orientation, - direction, isDisabled }; }