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
5 changes: 4 additions & 1 deletion packages/react-aria-components/src/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,10 @@ export const SliderFill = /*#__PURE__*/ (forwardRef as forwardRefType)(function
}
: {
position: 'absolute',
insetInlineStart: `${startPercent}%`,
// The thumb is positioned with the physical `left` property, so the fill must also be
// physical when the slider does not mirror. `insetInlineStart` would still resolve to
// `right` in an RTL document and the two would end up on opposite sides of the track.
[state.hasFixedDirection ? 'left' : 'insetInlineStart']: `${startPercent}%`,
width: `${sizePercent}%`,
height: '100%'
},
Expand Down
33 changes: 32 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,33 @@ const CustomThumb = ({index, children}: {index: number; children: React.ReactNod
</SliderThumb>
);
};

export const SliderFixedDirection: SliderStory = () => (
// Media controls such as a playback progress bar are conventionally laid out left to right
// even in RTL locales. Compare the two sliders below in an RTL locale: the first mirrors with
// the locale, the second stays left to right.
<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 (mirrors with the locale)</Label>
<SliderOutput />
</div>
<SliderTrack className={styles.track}>
<SliderFill />
<SliderThumb className={styles.thumb} />
</SliderTrack>
</Slider>
<Slider defaultValue={30} hasFixedDirection className={styles.slider}>
<div className={styles.label}>
<Label>Playback progress (hasFixedDirection)</Label>
<SliderOutput />
</div>
<SliderTrack className={styles.track}>
<SliderFill />
<SliderThumb className={styles.thumb} />
</SliderTrack>
</Slider>
</div>
</I18nProvider>
);
28 changes: 28 additions & 0 deletions packages/react-aria-components/test/Slider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -456,4 +456,32 @@ describe('Slider', () => {
);
expect(fill).toHaveStyle({position: 'absolute', bottom: '50%', height: '30%', width: '100%'});
});

describe('hasFixedDirection', () => {
it('positions SliderFill with insetInlineStart by default', () => {
let {getByRole} = render(<TestSlider sliderProps={{value: 30}} />);
let fill = getByRole('group').querySelector('.react-aria-SliderFill');
expect(fill).toHaveStyle({insetInlineStart: '0%', width: '30%'});
expect(fill.style.left).toBe('');
});

it('positions SliderFill with a physical left when hasFixedDirection is set', () => {
// insetInlineStart resolves to `right` in an RTL document, but the thumb is positioned
// with the physical `left`. A non-mirrored slider must use `left` for both so they stay
// on the same side of the track.
let {getByRole} = render(<TestSlider sliderProps={{value: 30, hasFixedDirection: true}} />);
let fill = getByRole('group').querySelector('.react-aria-SliderFill');
expect(fill).toHaveStyle({left: '0%', width: '30%'});
expect(fill.style.insetInlineStart).toBe('');
});

it('does not affect vertical SliderFill', () => {
let {getByRole} = render(
<TestSlider sliderProps={{value: 30, hasFixedDirection: true, orientation: 'vertical'}} />
);
let fill = getByRole('group').querySelector('.react-aria-SliderFill');
expect(fill).toHaveStyle({bottom: '0%', height: '30%'});
expect(fill.style.left).toBe('');
});
});
});
4 changes: 3 additions & 1 deletion packages/react-aria/src/slider/useSlider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ export function useSlider<T extends number | number[]>(
'aria-details': props['aria-details']
});

let {direction} = useLocale();
// Sliders that represent media playback rather than reading order are not mirrored in RTL locales.
let {direction: localeDirection} = useLocale();
let direction = state.hasFixedDirection ? 'ltr' : localeDirection;

let {addGlobalListener, removeGlobalListener} = useGlobalListeners();

Expand Down
4 changes: 3 additions & 1 deletion packages/react-aria/src/slider/useSliderThumb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ export function useSliderThumb(opts: AriaSliderThumbOptions, state: SliderState)
let isDisabled = opts.isDisabled || state.isDisabled;
let isVertical = orientation === 'vertical';

let {direction} = useLocale();
// Sliders that represent media playback rather than reading order are not mirrored in RTL locales.
let {direction: localeDirection} = useLocale();
let direction = state.hasFixedDirection ? 'ltr' : localeDirection;
let {addGlobalListener, removeGlobalListener} = useGlobalListeners();

let data = sliderData.get(state)!;
Expand Down
68 changes: 68 additions & 0 deletions packages/react-aria/test/slider/useSlider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -423,4 +430,65 @@ describe('useSlider', () => {
expect(stateRef.current.values).toEqual([10, 80]);
});
});

describe('hasFixedDirection', () => {
let widthStub;
beforeAll(() => {
widthStub = jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(() => ({top: 0, left: 0, width: 100, height: 100}));
});
afterAll(() => {
widthStub.mockReset();
});

installMouseEvent();

let stateRef = React.createRef();

function Example(props) {
let trackRef = useRef(null);
let state = useSliderState({...props, numberFormatter});
stateRef.current = state;
let {trackProps} = useSlider(props, state, trackRef);
return <div data-testid="track" ref={trackRef} {...trackProps} />;
}

function clickTrackAt(clientX) {
let track = screen.getByTestId('track');
fireEvent.mouseDown(track, {clientX, pageX: clientX});
fireEvent.mouseUp(track, {clientX, pageX: clientX});
}

// 25 is used rather than 50 so that the mirrored and non-mirrored results differ.
it('mirrors a track click in an RTL locale by default', () => {
render(
<I18nProvider locale="ar-AE">
<Example aria-label="Slider" defaultValue={[0]} />
</I18nProvider>
);
clickTrackAt(25);
expect(stateRef.current.values).toEqual([75]);
});

it('does not mirror a track click in an RTL locale when hasFixedDirection is set', () => {
render(
<I18nProvider locale="ar-AE">
<Example aria-label="Slider" defaultValue={[0]} hasFixedDirection />
</I18nProvider>
);
clickTrackAt(25);
expect(stateRef.current.values).toEqual([25]);
});

it('does not change track clicks in an LTR locale', () => {
render(
<I18nProvider locale="en-US">
<Example aria-label="Slider" defaultValue={[0]} hasFixedDirection />
</I18nProvider>
);
clickTrackAt(25);
expect(stateRef.current.values).toEqual([25]);
});
});
});
112 changes: 112 additions & 0 deletions packages/react-aria/test/slider/useSliderThumb.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -642,4 +643,115 @@ describe('useSliderThumb', () => {
});
});
});

describe('hasFixedDirection', () => {
let widthStub;
beforeAll(() => {
widthStub = jest
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(() => ({top: 0, left: 0, width: 100, height: 100}));
});
afterAll(() => {
widthStub.mockReset();
});

installMouseEvent();

let stateRef = React.createRef();

function Example(props) {
let trackRef = useRef(null);
let inputRef = useRef(null);
let state = useSliderState({...props, numberFormatter});
stateRef.current = state;
let {trackProps} = useSlider(props, state, trackRef);
let {inputProps, thumbProps} = useSliderThumb(
{...props, 'aria-label': 'Value', index: 0, trackRef, inputRef},
state
);
return (
<div data-testid="track" ref={trackRef} {...trackProps}>
<div data-testid="thumb" {...thumbProps}>
<input ref={inputRef} {...inputProps} />
</div>
</div>
);
}

function renderSlider(locale, props) {
return render(
<I18nProvider locale={locale}>
<Example aria-label="Slider" defaultValue={[25]} {...props} />
</I18nProvider>
);
}

// A value of 25 is used rather than 50 so the mirrored position (75%) and the
// non-mirrored position (25%) are distinguishable.
describe('thumb position', () => {
it('mirrors in an RTL locale by default', () => {
renderSlider('ar-AE');
expect(screen.getByTestId('thumb')).toHaveStyle({left: '75%'});
});

it('does not mirror in an RTL locale when hasFixedDirection is set', () => {
renderSlider('ar-AE', {hasFixedDirection: true});
expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'});
});

it('is unaffected in an LTR locale', () => {
renderSlider('en-US', {hasFixedDirection: true});
expect(screen.getByTestId('thumb')).toHaveStyle({left: '25%'});
});

it('still mirrors vertically regardless of hasFixedDirection', () => {
renderSlider('ar-AE', {hasFixedDirection: true, orientation: 'vertical'});
// Vertical sliders always run bottom to top, which hasFixedDirection must not change.
expect(screen.getByTestId('thumb')).toHaveStyle({top: '75%'});
});
});

describe('keyboard', () => {
it('reverses arrow keys in an RTL locale by default', async () => {
let user = userEvent.setup({delay: null, pointerMap});
renderSlider('ar-AE');
await user.tab();
await user.keyboard('{ArrowRight}');
expect(stateRef.current.values).toEqual([24]);
await user.keyboard('{ArrowLeft}');
expect(stateRef.current.values).toEqual([25]);
});

it('does not reverse arrow keys in an RTL locale when hasFixedDirection is set', async () => {
let user = userEvent.setup({delay: null, pointerMap});
renderSlider('ar-AE', {hasFixedDirection: true});
await user.tab();
await user.keyboard('{ArrowRight}');
expect(stateRef.current.values).toEqual([26]);
await user.keyboard('{ArrowLeft}');
expect(stateRef.current.values).toEqual([25]);
});
});

describe('dragging', () => {
it('reverses drag in an RTL locale by default', () => {
renderSlider('ar-AE');
let thumb = screen.getByTestId('thumb');
fireEvent.mouseDown(thumb, {clientX: 25, pageX: 25});
fireEvent.mouseMove(thumb, {clientX: 35, pageX: 35});
fireEvent.mouseUp(thumb, {clientX: 35, pageX: 35});
// Moving right decreases the value when mirrored.
expect(stateRef.current.values).toEqual([15]);
});

it('does not reverse drag in an RTL locale when hasFixedDirection is set', () => {
renderSlider('ar-AE', {hasFixedDirection: true});
let thumb = screen.getByTestId('thumb');
fireEvent.mouseDown(thumb, {clientX: 25, pageX: 25});
fireEvent.mouseMove(thumb, {clientX: 35, pageX: 35});
fireEvent.mouseUp(thumb, {clientX: 35, pageX: 35});
expect(stateRef.current.values).toEqual([35]);
});
});
});
});
15 changes: 14 additions & 1 deletion packages/react-stately/src/slider/useSliderState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ export interface SliderProps<T = number | number[]>
* @default 'horizontal'
*/
orientation?: Orientation;
/**
* Whether the slider's layout direction stays left-to-right in right-to-left locales.
* Controls that represent media playback rather than reading order, such as an audio
* progress bar, are conventionally not mirrored. Has no effect on vertical sliders.
*
* @default false
*/
hasFixedDirection?: boolean;
/** Whether the whole Slider is disabled. */
isDisabled?: boolean;
/** Fired when the slider stops moving, due to being let go. */
Expand Down Expand Up @@ -194,6 +202,9 @@ export interface SliderState {
/** The orientation of the slider. */
readonly orientation: Orientation;

/** Whether the slider's layout direction stays left-to-right in right-to-left locales. */
readonly hasFixedDirection: boolean;

/** Whether the slider is disabled. */
readonly isDisabled: boolean;
}
Expand Down Expand Up @@ -223,7 +234,8 @@ export function useSliderState<T extends number | number[]>(
maxValue = DEFAULT_MAX_VALUE,
numberFormatter: formatter,
step = DEFAULT_STEP_VALUE,
orientation = 'horizontal'
orientation = 'horizontal',
hasFixedDirection = false
} = props;

// Page step should be at least equal to step and always a multiple of the step.
Expand Down Expand Up @@ -403,6 +415,7 @@ export function useSliderState<T extends number | number[]>(
step,
pageSize,
orientation,
hasFixedDirection,
isDisabled
};
}
Expand Down