Skip to content

Have slider work like color area #2819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
109 changes: 63 additions & 46 deletions packages/@react-aria/slider/src/useSliderThumb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {getSliderThumbId, sliderIds} from './utils';
import React, {ChangeEvent, HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, RefObject, useCallback, useEffect, useRef} from 'react';
import {SliderState} from '@react-stately/slider';
import {useFocusable} from '@react-aria/focus';
import {useKeyboard, useMove} from '@react-aria/interactions';
import {useLabel} from '@react-aria/label';
import {useLocale} from '@react-aria/i18n';
import {useMove} from '@react-aria/interactions';

interface SliderThumbAria {
/** Props for the root thumb element; handles the dragging motion. */
Expand Down Expand Up @@ -77,44 +77,76 @@ export function useSliderThumb(
stateRef.current = state;
let reverseX = direction === 'rtl';
let currentPosition = useRef<number>(null);

let {keyboardProps} = useKeyboard({
onKeyDown(e) {
let {
getThumbMaxValue,
getThumbMinValue,
pageSize
} = stateRef.current;
// these are the cases that useMove or useSlider don't handle
if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) {
e.continuePropagation();
return;
}
// same handling as useMove, stopPropagation to prevent useSlider from handling the event as well.
e.preventDefault();
// remember to set this so that onChangeEnd is fired
state.setThumbDragging(index, true);
switch (e.key) {
case 'PageUp':
stateRef.current.incrementThumb(index, pageSize);
break;
case 'PageDown':
stateRef.current.decrementThumb(index, pageSize);
break;
case 'Home':
state.setThumbValue(index, getThumbMinValue(index));
break;
case 'End':
state.setThumbValue(index, getThumbMaxValue(index));
break;
}
state.setThumbDragging(index, false);
}
});

let {moveProps} = useMove({
onMoveStart({pointerType}) {
onMoveStart() {
currentPosition.current = null;
// Don't start dragging for keyboard events unless the value has changed.
if (pointerType !== 'keyboard') {
stateRef.current.setThumbDragging(index, true);
}
stateRef.current.setThumbDragging(index, true);
},
onMove({deltaX, deltaY, pointerType}) {
onMove({deltaX, deltaY, pointerType, shiftKey}) {
const {
getThumbPercent,
getThumbMaxValue,
getThumbMinValue,
getThumbValue,
setThumbDragging,
setThumbPercent,
setThumbValue,
step
getThumbPercent,
setThumbPercent,
step,
pageSize
} = stateRef.current;
let size = isVertical ? trackRef.current.offsetHeight : trackRef.current.offsetWidth;

if (currentPosition.current == null) {
currentPosition.current = getThumbPercent(index) * size;
}
if (pointerType === 'keyboard') {
const currentValue = getThumbValue(index);
// (invert left/right according to language direction) + (according to vertical)
const delta = ((reverseX ? -deltaX : deltaX) + (isVertical ? -deltaY : -deltaY)) * step;
if (delta > 0 && currentValue === getThumbMaxValue(index)) {
return;
}
if (delta < 0 && currentValue === getThumbMinValue(index)) {
return;
if (deltaX > 0) {
if (reverseX) {
stateRef.current.decrementThumb(index, shiftKey ? pageSize : step);
} else {
stateRef.current.incrementThumb(index, shiftKey ? pageSize : step);
}
} else if (deltaY < 0) {
stateRef.current.incrementThumb(index, shiftKey ? pageSize : step);
} else if (deltaX < 0) {
if (reverseX) {
stateRef.current.incrementThumb(index, shiftKey ? pageSize : step);
} else {
stateRef.current.decrementThumb(index, shiftKey ? pageSize : step);
}
} else if (deltaY > 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to group the deltaX and deltaY portions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I know what you're saying? I think this is pretty easy to follow, only X direction is influenced by locale direction

stateRef.current.decrementThumb(index, shiftKey ? pageSize : step);
}
currentPosition.current += delta * size;
setThumbDragging(index, true);
setThumbValue(index, currentValue + delta);
setThumbDragging(index, false);
} else {
let delta = isVertical ? deltaY : deltaX;
if (isVertical || reverseX) {
Expand All @@ -125,11 +157,8 @@ export function useSliderThumb(
setThumbPercent(index, clamp(currentPosition.current / size, 0, 1));
}
},
onMoveEnd({pointerType}) {
// Don't end dragging, triggering onChangeEnd, for keyboard events unless the value has changed.
if (pointerType !== 'keyboard') {
stateRef.current.setThumbDragging(index, false);
}
onMoveEnd() {
stateRef.current.setThumbDragging(index, false);
}
});

Expand Down Expand Up @@ -167,8 +196,6 @@ export function useSliderThumb(
}
};

let inputting = false;

// We install mouse handlers for the drag motion on the thumb div, but
// not the key handler for moving the thumb with the slider. Instead,
// we focus the range input, and let the browser handle the keyboard
Expand All @@ -187,22 +214,12 @@ export function useSliderThumb(
'aria-required': isRequired || undefined,
'aria-invalid': validationState === 'invalid' || undefined,
'aria-errormessage': opts['aria-errormessage'],
onInput: () => {
// Flag native keyboard input.
inputting = true;
},
onChange: (e: ChangeEvent<HTMLInputElement>) => {
if (parseFloat(e.target.value) !== state.getThumbValue(index)) {
// On native keyboard input, set thumb dragging so that onChangeEnd will be fired.
inputting && state.setThumbDragging(index, true);
state.setThumbValue(index, parseFloat(e.target.value));
// After native keyboard input, unset thumb dragging so that onChangeEnd will be fired.
inputting && state.setThumbDragging(index, false);
}
inputting = false;
stateRef.current.setThumbValue(index, parseFloat(e.target.value));
}
}),
thumbProps: !isDisabled ? mergeProps(
keyboardProps,
moveProps,
{
onMouseDown: (e: React.MouseEvent<HTMLElement>) => {
Expand Down
84 changes: 49 additions & 35 deletions packages/@react-aria/slider/test/useSliderThumb.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {act, fireEvent, render, screen} from '@testing-library/react';
import {fireEvent, render, screen} from '@testing-library/react';
import {installMouseEvent, installPointerEvent} from '@react-spectrum/test-utils';
import * as React from 'react';
import {renderHook} from '@testing-library/react-hooks';
Expand Down Expand Up @@ -384,54 +384,59 @@ describe('useSliderThumb', () => {
// Drag thumb
let thumb0 = screen.getByTestId('thumb').firstChild;
fireEvent.keyDown(thumb0, {key: 'ArrowRight'});
fireEvent.keyUp(thumb0, {key: 'ArrowRight'});
expect(onChangeSpy).toHaveBeenLastCalledWith([11]);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenLastCalledWith([11]);
expect(onChangeEndSpy).toHaveBeenCalledTimes(1);
expect(stateRef.current.values).toEqual([11]);

fireEvent.keyDown(thumb0, {key: 'ArrowLeft'});
fireEvent.keyUp(thumb0, {key: 'ArrowLeft'});
expect(onChangeSpy).toHaveBeenLastCalledWith([10]);
expect(onChangeSpy).toHaveBeenCalledTimes(2);
expect(onChangeEndSpy).toHaveBeenLastCalledWith([10]);
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
expect(stateRef.current.values).toEqual([10]);
});

// Note: Unlike ArrowLeft/ArrowRight/ArrowUp/ArrowDown added by the useMove hook,
// Home/End/PageUp/PageDown events are executed natively by the HTML input[type="range"],
// without the need for an additional keyboard event handlers in React Aria.
// This make it harder to test using the testing-library.

act(() => stateRef.current.setThumbPercent(0, 0));

onChangeSpy.mockClear();
onChangeEndSpy.mockClear();
it('can be moved with keys at the beginning of the slider', () => {
let onChangeSpy = jest.fn();
let onChangeEndSpy = jest.fn();
render(<Example onChange={onChangeSpy} onChangeEnd={onChangeEndSpy} aria-label="Slider" defaultValue={[0]} />);

let thumb0 = screen.getByTestId('thumb').firstChild;
fireEvent.keyDown(thumb0, {key: 'ArrowLeft'});
fireEvent.keyUp(thumb0, {key: 'ArrowLeft'});
expect(onChangeSpy).not.toHaveBeenCalled();
expect(onChangeEndSpy).not.toHaveBeenCalled();
expect(onChangeEndSpy).toHaveBeenCalledWith([0]);

fireEvent.keyDown(thumb0, {key: 'ArrowRight'});
fireEvent.keyUp(thumb0, {key: 'ArrowRight'});
expect(onChangeSpy).toHaveBeenLastCalledWith([1]);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenLastCalledWith([1]);
expect(onChangeEndSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
expect(stateRef.current.values).toEqual([1]);
});

act(() => stateRef.current.setThumbPercent(0, 1));

onChangeSpy.mockClear();
onChangeEndSpy.mockClear();
it('can be moved with keys at the end of the slider', () => {
let onChangeSpy = jest.fn();
let onChangeEndSpy = jest.fn();
render(<Example onChange={onChangeSpy} onChangeEnd={onChangeEndSpy} aria-label="Slider" defaultValue={[100]} />);

let thumb0 = screen.getByTestId('thumb').firstChild;
fireEvent.keyDown(thumb0, {key: 'ArrowRight'});
fireEvent.keyUp(thumb0, {key: 'ArrowRight'});
expect(onChangeSpy).not.toHaveBeenCalled();
expect(onChangeEndSpy).not.toHaveBeenCalled();
expect(onChangeEndSpy).toHaveBeenCalledWith([100]);

fireEvent.keyDown(thumb0, {key: 'ArrowLeft'});
fireEvent.keyUp(thumb0, {key: 'ArrowLeft'});
expect(onChangeSpy).toHaveBeenLastCalledWith([99]);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenLastCalledWith([99]);
expect(onChangeEndSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
expect(stateRef.current.values).toEqual([99]);
});

Expand All @@ -443,53 +448,62 @@ describe('useSliderThumb', () => {
// Drag thumb
let thumb0 = screen.getByTestId('thumb').firstChild;
fireEvent.keyDown(thumb0, {key: 'ArrowRight'});
fireEvent.keyUp(thumb0, {key: 'ArrowRight'});
expect(onChangeSpy).toHaveBeenLastCalledWith([11]);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
fireEvent.keyDown(thumb0, {key: 'ArrowUp'});
fireEvent.keyUp(thumb0, {key: 'ArrowUp'});
expect(onChangeSpy).toHaveBeenLastCalledWith([12]);
expect(onChangeSpy).toHaveBeenCalledTimes(2);
fireEvent.keyDown(thumb0, {key: 'ArrowDown'});
fireEvent.keyUp(thumb0, {key: 'ArrowDown'});
expect(onChangeSpy).toHaveBeenLastCalledWith([11]);
expect(onChangeSpy).toHaveBeenCalledTimes(3);
fireEvent.keyDown(thumb0, {key: 'ArrowLeft'});
fireEvent.keyUp(thumb0, {key: 'ArrowLeft'});
expect(onChangeSpy).toHaveBeenLastCalledWith([10]);
expect(onChangeSpy).toHaveBeenCalledTimes(4);
});

// Note: Unlike ArrowLeft/ArrowRight/ArrowUp/ArrowDown added by the useMove hook,
// Home/End/PageUp/PageDown events are executed natively by the HTML input[type="range"],
// without the need for an additional keyboard event handlers in React Aria.
// This make it harder to test using the testing-library.

act(() => stateRef.current.setThumbPercent(0, 0));

onChangeSpy.mockClear();
onChangeEndSpy.mockClear();
it('can be moved with keys (vertical) at the bottom of the slider', () => {
let onChangeSpy = jest.fn();
let onChangeEndSpy = jest.fn();
render(<Example onChange={onChangeSpy} onChangeEnd={onChangeEndSpy} aria-label="Slider" defaultValue={[0]} orientation="vertical" />);

// Drag thumb
let thumb0 = screen.getByTestId('thumb').firstChild;
fireEvent.keyDown(thumb0, {key: 'ArrowDown'});
fireEvent.keyUp(thumb0, {key: 'ArrowDown'});
expect(onChangeSpy).not.toHaveBeenCalled();
expect(onChangeEndSpy).not.toHaveBeenCalled();
expect(onChangeEndSpy).toHaveBeenCalledWith([0]);

fireEvent.keyDown(thumb0, {key: 'ArrowUp'});
fireEvent.keyUp(thumb0, {key: 'ArrowUp'});
expect(onChangeSpy).toHaveBeenLastCalledWith([1]);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenLastCalledWith([1]);
expect(onChangeEndSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
expect(stateRef.current.values).toEqual([1]);
});

act(() => stateRef.current.setThumbPercent(0, 1));

onChangeSpy.mockClear();
onChangeEndSpy.mockClear();
it('can be moved with keys (vertical) at the top of the slider', () => {
let onChangeSpy = jest.fn();
let onChangeEndSpy = jest.fn();
render(<Example onChange={onChangeSpy} onChangeEnd={onChangeEndSpy} aria-label="Slider" defaultValue={[100]} orientation="vertical" />);

// Drag thumb
let thumb0 = screen.getByTestId('thumb').firstChild;
fireEvent.keyDown(thumb0, {key: 'ArrowUp'});
fireEvent.keyUp(thumb0, {key: 'ArrowUp'});
expect(onChangeSpy).not.toHaveBeenCalled();
expect(onChangeEndSpy).not.toHaveBeenCalled();
expect(onChangeEndSpy).toHaveBeenCalledWith([100]);

fireEvent.keyDown(thumb0, {key: 'ArrowDown'});
fireEvent.keyUp(thumb0, {key: 'ArrowDown'});
expect(onChangeSpy).toHaveBeenLastCalledWith([99]);
expect(onChangeSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenLastCalledWith([99]);
expect(onChangeEndSpy).toHaveBeenCalledTimes(1);
expect(onChangeEndSpy).toHaveBeenCalledTimes(2);
expect(stateRef.current.values).toEqual([99]);
});
});
Expand Down
Loading