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..ed9c362bb2b 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, 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 }; }