diff --git a/packages/react-aria-components/src/Slider.tsx b/packages/react-aria-components/src/Slider.tsx index 7129ae20c18..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,6 +424,13 @@ 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 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, 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..5503043988e 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,69 @@ const CustomThumb = ({index, children}: {index: number; children: React.ReactNod ); }; + +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. +
+ +
+ +
+ + +
+ + + + +
+ +
+ +
+ + +
+ + + + +
+
+
+
+
+ +
+ +
+ + +
+ + + + +
+ +
+ +
+ + +
+ + + + +
+
+
+
+
+
+); diff --git a/packages/react-aria-components/test/Slider.test.js b/packages/react-aria-components/test/Slider.test.js index aa40ce9b5a0..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%' }); @@ -456,4 +457,63 @@ describe('Slider', () => { ); expect(fill).toHaveStyle({position: 'absolute', bottom: '50%', height: '30%', width: '100%'}); }); + + describe('direction', () => { + function renderWithDirection(locale, direction, sliderProps) { + return render( + + + + ); + } + + 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%'}); + }); + + 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('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%'}); + }); + + // 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/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 22b4a8519f6..3e79f7c9564 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,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, direction, 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', undefined, 25); + expect(stateRef.current.values).toEqual([75]); + }); + + it('does not mirror a track click in an LTR locale by default', () => { + clickTrackAt('en-US', undefined, 25); + expect(stateRef.current.values).toEqual([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 under an rtl I18nProvider', () => { + clickTrackAt('en-US', 'rtl', 25); + expect(stateRef.current.values).toEqual([75]); + }); + + 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 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 e65a4ae684f..572f38e9ac3 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, direction, 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 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 under an rtl I18nProvider', () => { + renderSlider('en-US', 'rtl'); + expect(screen.getByTestId('thumb')).toHaveStyle({left: '75%'}); + }); + + 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 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 under an rtl I18nProvider', () => { + renderSlider('en-US', '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 under an ltr I18nProvider', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + renderSlider('ar-AE', '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 under an rtl I18nProvider', async () => { + let user = userEvent.setup({delay: null, pointerMap}); + renderSlider('en-US', '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 under an ltr I18nProvider', () => { + renderSlider('ar-AE', 'ltr'); + dragThumbRight(); + expect(stateRef.current.values).toEqual([35]); + }); + + it('reverses drag in an LTR locale under an rtl I18nProvider', () => { + renderSlider('en-US', 'rtl'); + dragThumbRight(); + expect(stateRef.current.values).toEqual([15]); + }); + }); + }); });