Skip to content

Commit

Permalink
add touch events handling to slider (#265)
Browse files Browse the repository at this point in the history
* add touch events handling to slider

* add tests for slider touch events
  • Loading branch information
martynovs authored and giladgray committed Dec 2, 2016
1 parent 478ab73 commit fac7661
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 16 deletions.
25 changes: 20 additions & 5 deletions packages/core/src/components/slider/coreSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ export abstract class CoreSlider<P extends ICoreSliderProps> extends AbstractCom
[`${Classes.SLIDER}-unlabeled`]: this.props.renderLabel === false,
}, this.props.className);
return (
<div className={classes} onMouseDown={this.maybeHandleTrackClick}>
<div
className={classes}
onMouseDown={this.maybeHandleTrackClick}
onTouchStart={this.maybeHandleTrackTouch}
>
<div className={`${Classes.SLIDER}-track`} ref={this.refHandlers.track} />
{this.maybeRenderFill()}
{this.maybeRenderAxis()}
Expand All @@ -105,7 +109,8 @@ export abstract class CoreSlider<P extends ICoreSliderProps> extends AbstractCom
protected abstract renderHandles(): JSX.Element | JSX.Element[];
protected abstract renderFill(): JSX.Element;
/** An event listener invoked when the user clicks on the track outside a handle */
protected abstract handleTrackClick(event: MouseEvent | React.MouseEvent<HTMLElement>): void;
protected abstract handleTrackClick(event: React.MouseEvent<HTMLElement>): void;
protected abstract handleTrackTouch(event: React.TouchEvent<HTMLElement>): void;

protected formatLabel(value: number): React.ReactChild {
const { renderLabel } = this.props;
Expand Down Expand Up @@ -139,11 +144,21 @@ export abstract class CoreSlider<P extends ICoreSliderProps> extends AbstractCom
}

private maybeHandleTrackClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (this.canHandleTrackEvent(event)) {
this.handleTrackClick(event);
}
}

private maybeHandleTrackTouch = (event: React.TouchEvent<HTMLDivElement>) => {
if (this.canHandleTrackEvent(event)) {
this.handleTrackTouch(event);
}
}

private canHandleTrackEvent = (event: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
// ensure event does not come from inside the handle
if (!this.props.disabled && target.closest(`.${Classes.SLIDER_HANDLE}`) == null) {
this.handleTrackClick(event.nativeEvent as MouseEvent);
}
return !this.props.disabled && target.closest(`.${Classes.SLIDER_HANDLE}`) == null;
}

private updateTickSize() {
Expand Down
36 changes: 34 additions & 2 deletions packages/core/src/components/slider/handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export class Handle extends AbstractComponent<IHandleProps, IHandleState> {
onKeyDown={disabled ? null : this.handleKeyDown}
onKeyUp={disabled ? null : this.handleKeyUp}
onMouseDown={disabled ? null : this.beginHandleMovement}
onTouchStart={disabled ? null : this.beginHandleTouchMovement}
ref={this.refHandlers.handle}
style={{ left: Math.round((value - min) * tickSize - handleSize / 2) }}
tabIndex={0}
Expand All @@ -83,13 +84,25 @@ export class Handle extends AbstractComponent<IHandleProps, IHandleState> {
return value + valueDelta;
}

public touchEventClientX(event: TouchEvent | React.TouchEvent<HTMLElement>) {
return event.changedTouches[0].clientX;
}

public beginHandleMovement = (event: MouseEvent | React.MouseEvent<HTMLElement>) => {
document.addEventListener("mousemove", this.handleHandleMovement);
document.addEventListener("mouseup", this.endHandleMovement);
this.setState({ isMoving: true });
this.changeValue(this.clientToValue(event.clientX));
}

public beginHandleTouchMovement = (event: TouchEvent | React.TouchEvent<HTMLElement>) => {
document.addEventListener("touchmove", this.handleHandleTouchMovement);
document.addEventListener("touchend", this.endHandleTouchMovement);
document.addEventListener("touchcancel", this.endHandleTouchMovement);
this.setState({ isMoving: true });
this.changeValue(this.clientToValue(this.touchEventClientX(event)));
}

protected validateProps(props: IHandleProps) {
for (const prop of NUMBER_PROPS) {
if (typeof (props as any)[prop] !== "number") {
Expand All @@ -99,17 +112,33 @@ export class Handle extends AbstractComponent<IHandleProps, IHandleState> {
}

private endHandleMovement = (event: MouseEvent) => {
this.handleMoveEndedAt(event.clientX);
}

private endHandleTouchMovement = (event: TouchEvent) => {
this.handleMoveEndedAt(this.touchEventClientX(event));
}

private handleMoveEndedAt = (clientPixel: number) => {
this.removeDocumentEventListeners();
this.setState({ isMoving: false });
// not using changeValue because we want to invoke the handler regardless of current prop value
const { onRelease } = this.props;
const finalValue = this.clamp(this.clientToValue(event.clientX));
const finalValue = this.clamp(this.clientToValue(clientPixel));
safeInvoke(onRelease, finalValue);
}

private handleHandleMovement = (event: MouseEvent) => {
this.handleMovedTo(event.clientX);
}

private handleHandleTouchMovement = (event: TouchEvent) => {
this.handleMovedTo(this.touchEventClientX(event));
}

private handleMovedTo = (clientPixel: number) => {
if (this.state.isMoving && !this.props.disabled) {
this.changeValue(this.clientToValue(event.clientX));
this.changeValue(this.clientToValue(clientPixel));
}
}

Expand Down Expand Up @@ -148,5 +177,8 @@ export class Handle extends AbstractComponent<IHandleProps, IHandleState> {
private removeDocumentEventListeners() {
document.removeEventListener("mousemove", this.handleHandleMovement);
document.removeEventListener("mouseup", this.endHandleMovement);
document.removeEventListener("touchmove", this.handleHandleTouchMovement);
document.removeEventListener("touchend", this.endHandleTouchMovement);
document.removeEventListener("touchcancel", this.endHandleTouchMovement);
}
}
21 changes: 16 additions & 5 deletions packages/core/src/components/slider/rangeSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,28 @@ export class RangeSlider extends CoreSlider<IRangeSliderProps> {
));
}

protected handleTrackClick(event: MouseEvent | React.MouseEvent<HTMLElement>) {
protected handleTrackClick(event: React.MouseEvent<HTMLElement>) {
this.handles.reduce((min, handle) => {
// find closest handle to the mouse position
const value = handle.clientToValue(event.clientX);
if (Math.abs(value - handle.props.value) < Math.abs(value - min.props.value)) {
return handle;
}
return min;
return this.nearestHandleForValue(value, min, handle);
}).beginHandleMovement(event);
}

protected handleTrackTouch(event: React.TouchEvent<HTMLElement>) {
this.handles.reduce((min, handle) => {
// find closest handle to the touch position
const value = handle.clientToValue(handle.touchEventClientX(event));
return this.nearestHandleForValue(value, min, handle);
}).beginHandleTouchMovement(event);
}

protected nearestHandleForValue(value: number, firstHandle: Handle, secondHandle: Handle) {
const firstDistance = Math.abs(value - firstHandle.props.value);
const secondDistance = Math.abs(value - secondHandle.props.value);
return secondDistance < firstDistance ? secondHandle : firstHandle;
}

protected validateProps(props: IRangeSliderProps) {
const { value } = props;
if (value == null || value[RangeEnd.LEFT] == null || value[RangeEnd.RIGHT] == null) {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/components/slider/slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export class Slider extends CoreSlider<ISliderProps> {
}
}

protected handleTrackTouch(event: React.TouchEvent<HTMLElement>) {
if (this.handle != null) {
this.handle.beginHandleTouchMovement(event);
}
}

private handleHandleRef = (ref: Handle) => {
this.handle = ref;
}
Expand Down
23 changes: 21 additions & 2 deletions packages/core/test/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function detectBrowser() {

// see http://stackoverflow.com/questions/16802795/click-not-working-in-mocha-phantomjs-on-certain-elements
// tl;dr PhantomJS sucks so we have to manually create click events
export function dispatchMouseEvent(target: EventTarget, eventType = "click", clientX = 0, clientY = 0) {
export function createMouseEvent(eventType = "click", clientX = 0, clientY = 0) {
const event = document.createEvent("MouseEvent");
event.initMouseEvent(
eventType,
Expand All @@ -92,5 +92,24 @@ export function dispatchMouseEvent(target: EventTarget, eventType = "click", cli
0 /* left */,
null,
);
target.dispatchEvent(event);
return event;
}

export function dispatchMouseEvent(target: EventTarget, eventType = "click", clientX = 0, clientY = 0) {
target.dispatchEvent(createMouseEvent(eventType, clientX, clientY));
};

// PhantomJS doesn't support touch events yet https://github.com/ariya/phantomjs/issues/11571
// so we simulate it with mouse events
export function createTouchEvent(eventType = "touchstart", clientX = 0, clientY = 0) {
const event = createMouseEvent(eventType, clientX, clientY);
const touches = [{ clientX, clientY }];
["touches", "targetTouches", "changedTouches"].forEach((prop) => {
Object.defineProperty(event, prop, { value: touches });
});
return event;
}

export function dispatchTouchEvent(target: EventTarget, eventType = "touchstart", clientX = 0, clientY = 0) {
target.dispatchEvent(createTouchEvent(eventType, clientX, clientY));
}
63 changes: 62 additions & 1 deletion packages/core/test/slider/rangeSliderTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as React from "react";
import * as Keys from "../../src/common/keys";
import { Handle } from "../../src/components/slider/handle";
import { RangeSlider } from "../../src/index";
import { dispatchMouseEvent } from "../common/utils";
import { dispatchMouseEvent, dispatchTouchEvent } from "../common/utils";

describe("<RangeSlider>", () => {
let testsContainerElement: HTMLElement;
Expand Down Expand Up @@ -40,6 +40,16 @@ describe("<RangeSlider>", () => {
assert.deepEqual(changeSpy.args.map((arg) => arg[0]), [[1, 10], [2, 10], [3, 10], [4, 10]]);
});

it("moving touch on left handle updates first value in range", () => {
const changeSpy = sinon.spy();
const slider = renderSlider(<RangeSlider onChange={changeSpy} />);
slider.find(Handle).first().simulate("touchstart", { changedTouches: [{ clientX: 0 }] });
touchMove(slider.state("tickSize"), 5);
// called 4 times, for the move to 1, 2, 3, and 4
assert.equal(changeSpy.callCount, 4);
assert.deepEqual(changeSpy.args.map((arg) => arg[0]), [[1, 10], [2, 10], [3, 10], [4, 10]]);
});

it("moving mouse on right handle updates second value in range", () => {
const changeSpy = sinon.spy();
const slider = renderSlider(<RangeSlider onChange={changeSpy} />);
Expand All @@ -52,6 +62,18 @@ describe("<RangeSlider>", () => {
assert.deepEqual(changeSpy.args.map((arg) => arg[0]), [[0, 9], [0, 8], [0, 7], [0, 6]]);
});

it("moving touch on right handle updates second value in range", () => {
const changeSpy = sinon.spy();
const slider = renderSlider(<RangeSlider onChange={changeSpy} />);
const tickSize = slider.state("tickSize");
slider.find(Handle).last().simulate("touchstart", { changedTouches: [{ clientX: tickSize * 10 }] });
// move leftwards because it defaults to the max value
touchMove(-tickSize, 5, tickSize * 10);
// called 4 times, for the move to 9, 8, 7, and 6
assert.equal(changeSpy.callCount, 4);
assert.deepEqual(changeSpy.args.map((arg) => arg[0]), [[0, 9], [0, 8], [0, 7], [0, 6]]);
});

it("releasing mouse calls onRelease with nearest value", () => {
const releaseSpy = sinon.spy();
const slider = renderSlider(<RangeSlider onRelease={releaseSpy} />);
Expand All @@ -61,6 +83,15 @@ describe("<RangeSlider>", () => {
assert.deepEqual(releaseSpy.args[0][0], [0, 4]);
});

it("releasing touch calls onRelease with nearest value", () => {
const releaseSpy = sinon.spy();
const slider = renderSlider(<RangeSlider onRelease={releaseSpy} />);
slider.find(Handle).last().simulate("touchstart", { changedTouches: [{ clientX: 0 }] });
touchEnd(slider.state("tickSize") * 4);
assert.isTrue(releaseSpy.calledOnce, "onRelease not called exactly once");
assert.deepEqual(releaseSpy.args[0][0], [0, 4]);
});

it("releasing mouse on same value calls onRelease but not onChange", () => {
const releaseSpy = sinon.spy();
const changeSpy = sinon.spy();
Expand All @@ -73,6 +104,18 @@ describe("<RangeSlider>", () => {
assert.isTrue(changeSpy.notCalled, "onChange was called when value hasn't changed");
});

it("releasing touch on same value calls onRelease but not onChange", () => {
const releaseSpy = sinon.spy();
const changeSpy = sinon.spy();
renderSlider(<RangeSlider onChange={changeSpy} onRelease={releaseSpy} />)
.find(Handle).first()
.simulate("touchstart", { changedTouches: [{ clientX: 0 }] });
touchEnd();
assert.isTrue(releaseSpy.calledOnce, "onRelease not called exactly once");
assert.deepEqual(releaseSpy.args[0][0], [0, 10]);
assert.isTrue(changeSpy.notCalled, "onChange was called when value hasn't changed");
});

it("disabled slider does not respond to mouse movement", () => {
const changeSpy = sinon.spy();
const slider = renderSlider(<RangeSlider disabled={true} onChange={changeSpy} />);
Expand All @@ -81,6 +124,14 @@ describe("<RangeSlider>", () => {
assert.isTrue(changeSpy.notCalled, "onChange was called when disabled");
});

it("disabled slider does not respond to touch movement", () => {
const changeSpy = sinon.spy();
const slider = renderSlider(<RangeSlider disabled={true} onChange={changeSpy} />);
slider.find(Handle).first().simulate("touchstart", { changedTouches: [{ clientX: 0 }] });
touchMove(slider.state("tickSize"), 5);
assert.isTrue(changeSpy.notCalled, "onChange was called when disabled");
});

it("disabled slider does not respond to key presses", () => {
const changeSpy = sinon.spy();
const handles = renderSlider(<RangeSlider disabled={true} onChange={changeSpy} />).find(Handle);
Expand All @@ -102,4 +153,14 @@ describe("<RangeSlider>", () => {
function mouseUp(clientX = 0) {
dispatchMouseEvent(document, "mouseup", clientX);
}

function touchMove(movement: number, times = 1, initialValue = 0) {
for (let x = 0; x < times; x += 1) {
dispatchTouchEvent(document, "touchmove", initialValue + x * movement);
}
}

function touchEnd(clientX = 0) {
dispatchTouchEvent(document, "touchend", clientX);
}
});
Loading

1 comment on commit fac7661

@blueprint-bot
Copy link

Choose a reason for hiding this comment

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

add touch events handling to slider (#265)

Preview: docs
Coverage: core | datetime

Please sign in to comment.