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 @@ -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',
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
60 changes: 59 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,60 @@ const CustomThumb = ({index, children}: {index: number; children: React.ReactNod
</SliderThumb>
);
};

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.
<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>
<Slider defaultValue={30} direction="ltr" className={styles.slider}>
<div className={styles.label}>
<Label>Playback progress (direction=&quot;ltr&quot;)</Label>
<SliderOutput />
</div>
<SliderTrack className={styles.track}>
<SliderFill />
<SliderThumb className={styles.thumb} />
</SliderTrack>
</Slider>
</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>
<Slider defaultValue={30} direction="rtl" className={styles.slider}>
<div className={styles.label}>
<Label>Manga page position (direction=&quot;rtl&quot;)</Label>
<SliderOutput />
</div>
<SliderTrack className={styles.track}>
<SliderFill />
<SliderThumb className={styles.thumb} />
</SliderTrack>
</Slider>
</div>
</I18nProvider>
</div>
);
50 changes: 50 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,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(<TestSlider sliderProps={{value: 30}} />);
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(<TestSlider sliderProps={{value: 30, direction: 'ltr'}} />);
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(<TestSlider sliderProps={{value: 30, direction: 'rtl'}} />);
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(
<TestSlider sliderProps={{value: 30, direction: 'ltr', orientation: 'vertical'}} />
);
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(
<TestSlider sliderProps={{value: 30, direction: 'rtl', orientation: 'vertical'}} />
);
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('');
});
});
});
5 changes: 4 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,10 @@ export function useSlider<T extends number | number[]>(
'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();

Expand Down
5 changes: 4 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,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)!;
Expand Down
74 changes: 74 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,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 <div data-testid="track" ref={trackRef} {...trackProps} />;
}

function clickTrackAt(locale, props, clientX) {
render(
<I18nProvider locale={locale}>
<Example aria-label="Slider" defaultValue={[0]} {...props} />
</I18nProvider>
);
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]);
});
});
});
Loading