Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/react-aria-components/src/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand All @@ -436,7 +444,7 @@ export const SliderFill = /*#__PURE__*/ (forwardRef as forwardRefType)(function
}
: {
position: 'absolute',
insetInlineStart: `${startPercent}%`,
[fillInset]: `${startPercent}%`,
width: `${sizePercent}%`,
height: '100%'
},
Expand Down
69 changes: 68 additions & 1 deletion packages/react-aria-components/stories/Slider.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -119,3 +120,69 @@ const CustomThumb = ({index, children}: {index: number; children: React.ReactNod
</SliderThumb>
);
};

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.
<div style={{display: 'flex', gap: 48}}>
<I18nProvider locale="ar-AE">
<div dir="rtl" style={{display: 'flex', flexDirection: 'column', gap: 24}}>
<Slider defaultValue={30} className={styles.slider}>
<div className={styles.label}>
<Label>Volume (follows the locale)</Label>
<SliderOutput />
</div>
<SliderTrack className={styles.track}>
<SliderFill />
<SliderThumb className={styles.thumb} />
</SliderTrack>
</Slider>
<I18nProvider locale="ar-AE" direction="ltr">
<div dir="ltr">
<Slider defaultValue={30} className={styles.slider}>
<div className={styles.label}>
<Label>Playback progress (pinned ltr)</Label>
<SliderOutput />
</div>
<SliderTrack className={styles.track}>
<SliderFill />
<SliderThumb className={styles.thumb} />
</SliderTrack>
</Slider>
</div>
</I18nProvider>
</div>
</I18nProvider>
<I18nProvider locale="en-US">
<div dir="ltr" style={{display: 'flex', flexDirection: 'column', gap: 24}}>
<Slider defaultValue={30} className={styles.slider}>
<div className={styles.label}>
<Label>Volume (follows the locale)</Label>
<SliderOutput />
</div>
<SliderTrack className={styles.track}>
<SliderFill />
<SliderThumb className={styles.thumb} />
</SliderTrack>
</Slider>
<I18nProvider locale="en-US" direction="rtl">
<div dir="rtl">
<Slider defaultValue={30} className={styles.slider}>
<div className={styles.label}>
<Label>Manga page position (pinned rtl)</Label>
<SliderOutput />
</div>
<SliderTrack className={styles.track}>
<SliderFill />
<SliderThumb className={styles.thumb} />
</SliderTrack>
</Slider>
</div>
</I18nProvider>
</div>
</I18nProvider>
</div>
);
66 changes: 63 additions & 3 deletions packages/react-aria-components/test/Slider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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%'
});
Expand All @@ -420,15 +421,15 @@ describe('Slider', () => {
expect(fill).toHaveAttribute('data-orientation', 'horizontal');
expect(fill).toHaveStyle({
position: 'absolute',
insetInlineStart: '30%',
left: '30%',
width: '20%',
height: '100%'
});

rerender(<TestSlider sliderProps={{value: 80}} fillProps={{offset: 50}} />);
expect(fill).toHaveStyle({
position: 'absolute',
insetInlineStart: '50%',
left: '50%',
width: '30%',
height: '100%'
});
Expand Down Expand Up @@ -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(
<I18nProvider locale={locale} direction={direction}>
<TestSlider sliderProps={{value: 30, ...sliderProps}} />
</I18nProvider>
);
}

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('');
});
});
});
31 changes: 23 additions & 8 deletions packages/react-aria/src/i18n/I18nProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Locale | null>(null);
Expand All @@ -39,45 +49,50 @@ 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 <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

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 <I18nContext.Provider value={defaultLocale}>{children}</I18nContext.Provider>;
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

/**
* 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 <I18nProviderWithLocale locale={locale} children={children} />;
return <I18nProviderWithLocale locale={locale} direction={direction} children={children} />;
}

return <I18nProviderWithDefaultLocale children={children} />;
return <I18nProviderWithDefaultLocale direction={direction} children={children} />;
}

/**
Expand Down
83 changes: 83 additions & 0 deletions packages/react-aria/test/i18n/I18nProvider.test.js
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<div data-testid="locale">{locale}</div>
<div data-testid="direction">{direction}</div>
</div>
);
}

function renderProvider(props) {
render(
<I18nProvider {...props}>
<TestComponent />
</I18nProvider>
);
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(
<I18nProvider locale="ar-AE" direction="ltr">
<I18nProvider locale="ar-AE">
<TestComponent />
</I18nProvider>
</I18nProvider>
);
expect(screen.getByTestId('direction')).toHaveTextContent('rtl');
});
});
Loading
Loading