From fac7661bc65775b7b7ce2bff1be75bef52938bfc Mon Sep 17 00:00:00 2001 From: Sergey Martynov Date: Fri, 2 Dec 2016 23:22:00 +0300 Subject: [PATCH] add touch events handling to slider (#265) * add touch events handling to slider * add tests for slider touch events --- .../core/src/components/slider/coreSlider.tsx | 25 ++++++-- .../core/src/components/slider/handle.tsx | 36 ++++++++++- .../src/components/slider/rangeSlider.tsx | 21 +++++-- .../core/src/components/slider/slider.tsx | 6 ++ packages/core/test/common/utils.ts | 23 ++++++- .../core/test/slider/rangeSliderTests.tsx | 63 ++++++++++++++++++- packages/core/test/slider/sliderTests.tsx | 50 ++++++++++++++- 7 files changed, 208 insertions(+), 16 deletions(-) diff --git a/packages/core/src/components/slider/coreSlider.tsx b/packages/core/src/components/slider/coreSlider.tsx index 371038656f..caa1f288ab 100644 --- a/packages/core/src/components/slider/coreSlider.tsx +++ b/packages/core/src/components/slider/coreSlider.tsx @@ -85,7 +85,11 @@ export abstract class CoreSlider

extends AbstractCom [`${Classes.SLIDER}-unlabeled`]: this.props.renderLabel === false, }, this.props.className); return ( -

+
{this.maybeRenderFill()} {this.maybeRenderAxis()} @@ -105,7 +109,8 @@ export abstract class CoreSlider

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): void; + protected abstract handleTrackClick(event: React.MouseEvent): void; + protected abstract handleTrackTouch(event: React.TouchEvent): void; protected formatLabel(value: number): React.ReactChild { const { renderLabel } = this.props; @@ -139,11 +144,21 @@ export abstract class CoreSlider

extends AbstractCom } private maybeHandleTrackClick = (event: React.MouseEvent) => { + if (this.canHandleTrackEvent(event)) { + this.handleTrackClick(event); + } + } + + private maybeHandleTrackTouch = (event: React.TouchEvent) => { + if (this.canHandleTrackEvent(event)) { + this.handleTrackTouch(event); + } + } + + private canHandleTrackEvent = (event: React.MouseEvent | React.TouchEvent) => { 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() { diff --git a/packages/core/src/components/slider/handle.tsx b/packages/core/src/components/slider/handle.tsx index 5267e2c361..9e9e60841e 100644 --- a/packages/core/src/components/slider/handle.tsx +++ b/packages/core/src/components/slider/handle.tsx @@ -58,6 +58,7 @@ export class Handle extends AbstractComponent { 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} @@ -83,6 +84,10 @@ export class Handle extends AbstractComponent { return value + valueDelta; } + public touchEventClientX(event: TouchEvent | React.TouchEvent) { + return event.changedTouches[0].clientX; + } + public beginHandleMovement = (event: MouseEvent | React.MouseEvent) => { document.addEventListener("mousemove", this.handleHandleMovement); document.addEventListener("mouseup", this.endHandleMovement); @@ -90,6 +95,14 @@ export class Handle extends AbstractComponent { this.changeValue(this.clientToValue(event.clientX)); } + public beginHandleTouchMovement = (event: TouchEvent | React.TouchEvent) => { + 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") { @@ -99,17 +112,33 @@ export class Handle extends AbstractComponent { } 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)); } } @@ -148,5 +177,8 @@ export class Handle extends AbstractComponent { 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); } } diff --git a/packages/core/src/components/slider/rangeSlider.tsx b/packages/core/src/components/slider/rangeSlider.tsx index 32b83e32db..eba9cc59bf 100644 --- a/packages/core/src/components/slider/rangeSlider.tsx +++ b/packages/core/src/components/slider/rangeSlider.tsx @@ -83,17 +83,28 @@ export class RangeSlider extends CoreSlider { )); } - protected handleTrackClick(event: MouseEvent | React.MouseEvent) { + protected handleTrackClick(event: React.MouseEvent) { 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) { + 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) { diff --git a/packages/core/src/components/slider/slider.tsx b/packages/core/src/components/slider/slider.tsx index 1b6b007989..969c6acf07 100644 --- a/packages/core/src/components/slider/slider.tsx +++ b/packages/core/src/components/slider/slider.tsx @@ -78,6 +78,12 @@ export class Slider extends CoreSlider { } } + protected handleTrackTouch(event: React.TouchEvent) { + if (this.handle != null) { + this.handle.beginHandleTouchMovement(event); + } + } + private handleHandleRef = (ref: Handle) => { this.handle = ref; } diff --git a/packages/core/test/common/utils.ts b/packages/core/test/common/utils.ts index 49cb847fbf..3d1084c1df 100644 --- a/packages/core/test/common/utils.ts +++ b/packages/core/test/common/utils.ts @@ -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, @@ -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)); +} diff --git a/packages/core/test/slider/rangeSliderTests.tsx b/packages/core/test/slider/rangeSliderTests.tsx index 38203b2d68..b3c1ac75c6 100644 --- a/packages/core/test/slider/rangeSliderTests.tsx +++ b/packages/core/test/slider/rangeSliderTests.tsx @@ -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("", () => { let testsContainerElement: HTMLElement; @@ -40,6 +40,16 @@ describe("", () => { 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(); + 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(); @@ -52,6 +62,18 @@ describe("", () => { 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(); + 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(); @@ -61,6 +83,15 @@ describe("", () => { assert.deepEqual(releaseSpy.args[0][0], [0, 4]); }); + it("releasing touch calls onRelease with nearest value", () => { + const releaseSpy = sinon.spy(); + const slider = renderSlider(); + 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(); @@ -73,6 +104,18 @@ describe("", () => { 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() + .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(); @@ -81,6 +124,14 @@ describe("", () => { 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(); + 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().find(Handle); @@ -102,4 +153,14 @@ describe("", () => { 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); + } }); diff --git a/packages/core/test/slider/sliderTests.tsx b/packages/core/test/slider/sliderTests.tsx index fe96eb60f0..ba6ca3ee4b 100644 --- a/packages/core/test/slider/sliderTests.tsx +++ b/packages/core/test/slider/sliderTests.tsx @@ -12,7 +12,7 @@ import * as React from "react"; import * as Keys from "../../src/common/keys"; import { Handle } from "../../src/components/slider/handle"; import { Classes, Slider } from "../../src/index"; -import { dispatchMouseEvent } from "../common/utils"; +import { dispatchMouseEvent, dispatchTouchEvent } from "../common/utils"; describe("", () => { let testsContainerElement: HTMLElement; @@ -74,6 +74,26 @@ describe("", () => { assert.equal(releaseSpy.args[0][0], 1); }); + it("moving touch calls onChange with nearest value", () => { + const changeSpy = sinon.spy(); + const slider = renderSlider() + .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, 2, 3, 4]); + }); + + it("releasing touch calls onRelease with nearest value", () => { + const releaseSpy = sinon.spy(); + const slider = renderSlider() + .simulate("touchstart", { changedTouches: [{ clientX: 0 }] }); + touchMove(slider.state("tickSize"), 1); + touchEnd(slider.state("tickSize")); + assert.isTrue(releaseSpy.calledOnce, "onRelease not called exactly once"); + assert.equal(releaseSpy.args[0][0], 1); + }); + it("pressing arrow key down reduces value by stepSize", () => { const changeSpy = sinon.spy(); renderSlider() @@ -107,6 +127,14 @@ describe("", () => { 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() + .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(); renderSlider() @@ -125,6 +153,16 @@ describe("", () => { assert.isTrue(trackClickSpy.notCalled, "handleTrackClick was called when disabled"); }); + it("disabled slider does not respond to track taps", () => { + const trackSelector = `.${Classes.SLIDER}-track`; + const slider = renderSlider(); + // spy on instance method instead of onChange because we can't supply nativeEvent + const trackClickSpy = sinon.spy(slider.instance(), "handleTrackTouch"); + slider.find(trackSelector) + .simulate("touchstart", { target: testsContainerElement.query(trackSelector) }); + assert.isTrue(trackClickSpy.notCalled, "handleTrackTouch was called when disabled"); + }); + it("throws error if given non-number values for number props", () => { [{ max: "foo" }, { min: "foo" }, { stepSize: "foo" }].forEach((props: any) => { assert.throws(() => renderSlider(), "number"); @@ -150,4 +188,14 @@ describe("", () => { function mouseUp(clientX = 0) { dispatchMouseEvent(document, "mouseup", clientX); } + + function touchMove(movement: number, times = 1) { + for (let x = 0; x < times; x += 1) { + dispatchTouchEvent(document, "touchmove", x * movement); + } + } + + function touchEnd(clientX = 0) { + dispatchTouchEvent(document, "touchend", clientX); + } });