From 322f2b69298ce29bb493d23ff0c2f569f1e8b10c Mon Sep 17 00:00:00 2001 From: Travis Kaufman Date: Tue, 21 Mar 2017 12:52:49 -0400 Subject: [PATCH] feat(ripple): Implement subset of improved interaction response guidelines for ripple Background === When the ripple was originally designed from a motion perspective, bounded and unbounded activation/deactivation used two different animation styles, distinct from one another. The UX updates to the ripple remove this distinction of bounded vs. unbounded solely with regard to how the ripple animates in and out. There is still a distinction between bounded vs. unbounded, but we no longer have to account for this when animating the ripple. Thus, there are no API changes; only UX changes. Changes === - Implement UX changes for "tap" + "tap and hold" interactions - Remove all references of "bounded" vs. "unbounded" from activation/deactivation code Resolves #190 --- packages/mdc-ripple/_keyframes.scss | 29 +- packages/mdc-ripple/_mixins.scss | 57 +-- packages/mdc-ripple/constants.js | 42 +- packages/mdc-ripple/foundation.js | 229 +++++----- packages/mdc-ripple/package.json | 1 - packages/mdc-ripple/util.js | 17 +- test/unit/mdc-checkbox/mdc-checkbox.test.js | 4 +- .../mdc-ripple/foundation-activation.test.js | 221 +++++++++- .../foundation-deactivation.test.js | 393 ++++++++---------- .../foundation-general-events.test.js | 4 +- test/unit/mdc-ripple/foundation.test.js | 41 +- test/unit/mdc-ripple/helpers.js | 3 +- test/unit/mdc-ripple/util.test.js | 86 +--- 13 files changed, 520 insertions(+), 607 deletions(-) diff --git a/packages/mdc-ripple/_keyframes.scss b/packages/mdc-ripple/_keyframes.scss index 114ab410fe8..cdb172d1b8d 100644 --- a/packages/mdc-ripple/_keyframes.scss +++ b/packages/mdc-ripple/_keyframes.scss @@ -20,46 +20,37 @@ @keyframes mdc-ripple-fg-radius-in { from { - transform: translate(0) scale(1); + // NOTE: For these keyframes, we do not need custom property fallbacks because they are only + // used in conjunction with `.mdc-ripple-upgraded`. Since MDCRippleFoundation checks to ensure + // that custom properties are supported within the browser before adding this class, we can + // safely use them without a fallback. transform: translate(var(--mdc-ripple-fg-translate-start, 0)) scale(1); animation-timing-function: $mdc-animation-fast-out-slow-in-timing-function; } to { - transform: translate(0) scale(0); - transform: translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 0)); + transform: translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1)); } } -@keyframes mdc-ripple-fg-opacity-out { +@keyframes mdc-ripple-fg-opacity-in { from { - opacity: 1; + opacity: 0; animation-timing-function: linear; } to { - opacity: 0; + opacity: 1; } } -@keyframes mdc-ripple-fg-unbounded-opacity-deactivate { +@keyframes mdc-ripple-fg-opacity-out { from { opacity: 1; + animation-timing-function: linear; } to { opacity: 0; } } - -@keyframes mdc-ripple-fg-unbounded-transform-deactivate { - from { - transform: 0; - transform: var(--mdc-ripple-fg-approx-xf, 0); - } - - to { - transform: scale(0); - transform: scale(var(--mdc-ripple-fg-scale, 0)); - } -} diff --git a/packages/mdc-ripple/_mixins.scss b/packages/mdc-ripple/_mixins.scss index 6afc40aa1db..8747a37f611 100644 --- a/packages/mdc-ripple/_mixins.scss +++ b/packages/mdc-ripple/_mixins.scss @@ -8,7 +8,8 @@ // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // @@ -28,17 +29,14 @@ } @mixin mdc-ripple-base() { + --mdc-ripple-surface-width: 0; + --mdc-ripple-surface-height: 0; + --mdc-ripple-fg-size: 0; --mdc-ripple-left: 0; --mdc-ripple-top: 0; - --mdc-ripple-fg-size: 0; - --mdc-ripple-surface-height: 0; - --mdc-ripple-surface-width: 0; - --mdc-ripple-fg-unbounded-transform-duration: 0ms; - --mdc-ripple-xfo-x: center; - --mdc-ripple-xfo-y: center; - --mdc-ripple-fg-unbounded-opacity-duration: 0ms; - --mdc-ripple-fg-unbounded-transform-duration: 0ms; - --mdc-ripple-fg-approx-xf: 0; + --mdc-ripple-fg-scale: 1; + --mdc-ripple-fg-translate-end: 0; + --mdc-ripple-fg-translate-start: 0; will-change: transform, opacity; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); @@ -121,18 +119,11 @@ transform: scale(var(--mdc-ripple-fg-scale, 0)); } - &.mdc-ripple-upgraded--background-active#{$pseudo} { + &.mdc-ripple-upgraded--background-focused#{$pseudo} { opacity: .99999; } - // When an element goes active, a foreground ripple will be triggered. - // Therefore, we adjust the transition duration for the correct "wind- - // up" animation. - &.mdc-ripple-upgraded--background-active:active#{$pseudo} { - transition-duration: 600ms; - } - - &.mdc-ripple-upgraded--background-bounded-active-fill#{$pseudo} { + &.mdc-ripple-upgraded--background-active-fill#{$pseudo} { transition-duration: 120ms; opacity: 1; } @@ -215,29 +206,13 @@ transform-origin: center center; } - &.mdc-ripple-upgraded--foreground-bounded-active-fill#{$pseudo} { - animation-fill-mode: forwards; - animation: 300ms mdc-ripple-fg-radius-in, 400ms mdc-ripple-fg-opacity-out; - } - - &.mdc-ripple-upgraded--unbounded.mdc-ripple-upgraded--foreground-unbounded-activation#{$pseudo} { - transform: scale(0); - transform: scale(var(--mdc-ripple-fg-scale, 0)); - transition: - opacity 110ms linear, - transform 0 linear 80ms; - transition: - opacity 110ms linear, - transform var(--mdc-ripple-fg-unbounded-transform-duration, 0) linear 80ms; - opacity: 1; + &.mdc-ripple-upgraded--foreground-activation#{$pseudo} { + animation: 300ms mdc-ripple-fg-radius-in forwards, 83ms mdc-ripple-fg-opacity-in forwards; } - &.mdc-ripple-upgraded--unbounded.mdc-ripple-upgraded--foreground-unbounded-deactivation#{$pseudo} { - animation: - mdc-ripple-fg-unbounded-opacity-deactivate 0 linear, - mdc-ripple-fg-unbounded-transform-deactivate 0 $mdc-animation-fast-out-slow-in-timing-function; - animation: - mdc-ripple-fg-unbounded-opacity-deactivate var(--mdc-ripple-fg-unbounded-opacity-duration, 0) linear, - mdc-ripple-fg-unbounded-transform-deactivate var(--mdc-ripple-fg-unbounded-transform-duration, 0) $mdc-animation-fast-out-slow-in-timing-function; + &.mdc-ripple-upgraded--foreground-deactivation#{$pseudo} { + // Retain transform from mdc-ripple-fg-radius-in activation + transform: translate(var(--mdc-ripple-fg-translate-end, 0)) scale(var(--mdc-ripple-fg-scale, 1)); + animation: 250ms mdc-ripple-fg-opacity-out; } } diff --git a/packages/mdc-ripple/constants.js b/packages/mdc-ripple/constants.js index 6e33f6f80b9..65f9714ee3e 100644 --- a/packages/mdc-ripple/constants.js +++ b/packages/mdc-ripple/constants.js @@ -14,43 +14,31 @@ * limitations under the License. */ -export const ROOT = 'mdc-ripple'; -export const UPGRADED = `${ROOT}-upgraded`; - export const cssClasses = { // Ripple is a special case where the "root" component is really a "mixin" of sorts, // given that it's an 'upgrade' to an existing component. That being said it is the root // CSS class that all other CSS classes derive from. - ROOT: UPGRADED, - UNBOUNDED: `${UPGRADED}--unbounded`, - BG_ACTIVE: `${UPGRADED}--background-active`, - BG_BOUNDED_ACTIVE_FILL: `${UPGRADED}--background-bounded-active-fill`, - FG_BOUNDED_ACTIVE_FILL: `${UPGRADED}--foreground-bounded-active-fill`, - FG_UNBOUNDED_ACTIVATION: `${UPGRADED}--foreground-unbounded-activation`, - FG_UNBOUNDED_DEACTIVATION: `${UPGRADED}--foreground-unbounded-deactivation`, + ROOT: 'mdc-ripple-upgraded', + UNBOUNDED: 'mdc-ripple-upgraded--unbounded', + BG_FOCUSED: 'mdc-ripple-upgraded--background-focused', + BG_ACTIVE_FILL: 'mdc-ripple-upgraded--background-active-fill', + FG_ACTIVATION: 'mdc-ripple-upgraded--foreground-activation', + FG_DEACTIVATION: 'mdc-ripple-upgraded--foreground-deactivation', }; export const strings = { - VAR_SURFACE_WIDTH: `--${ROOT}-surface-width`, - VAR_SURFACE_HEIGHT: `--${ROOT}-surface-height`, - VAR_FG_SIZE: `--${ROOT}-fg-size`, - VAR_FG_UNBOUNDED_OPACITY_DURATION: `--${ROOT}-fg-unbounded-opacity-duration`, - VAR_FG_UNBOUNDED_TRANSFORM_DURATION: `--${ROOT}-fg-unbounded-transform-duration`, - VAR_LEFT: `--${ROOT}-left`, - VAR_TOP: `--${ROOT}-top`, - VAR_TRANSLATE_END: `--${ROOT}-translate-end`, - VAR_FG_APPROX_XF: `--${ROOT}-fg-approx-xf`, - VAR_FG_SCALE: `--${ROOT}-fg-scale`, - VAR_FG_TRANSLATE_START: `--${ROOT}-fg-translate-start`, - VAR_FG_TRANSLATE_END: `--${ROOT}-fg-translate-end`, + VAR_SURFACE_WIDTH: '--mdc-ripple-surface-width', + VAR_SURFACE_HEIGHT: '--mdc-ripple-surface-height', + VAR_FG_SIZE: '--mdc-ripple-fg-size', + VAR_LEFT: '--mdc-ripple-left', + VAR_TOP: '--mdc-ripple-top', + VAR_FG_SCALE: '--mdc-ripple-fg-scale', + VAR_FG_TRANSLATE_START: '--mdc-ripple-fg-translate-start', + VAR_FG_TRANSLATE_END: '--mdc-ripple-fg-translate-end', }; export const numbers = { - FG_TRANSFORM_DELAY_MS: 80, - OPACITY_DURATION_DIVISOR: 3, - ACTIVE_OPACITY_DURATION_MS: 110, - MIN_OPACITY_DURATION_MS: 200, - UNBOUNDED_TRANSFORM_DURATION_MS: 200, PADDING: 10, INITIAL_ORIGIN_SCALE: 0.6, + DEACTIVATION_TIMEOUT_MS: 300, }; diff --git a/packages/mdc-ripple/foundation.js b/packages/mdc-ripple/foundation.js index f5c510cf5e2..d4c77621918 100644 --- a/packages/mdc-ripple/foundation.js +++ b/packages/mdc-ripple/foundation.js @@ -16,9 +16,8 @@ import {MDCFoundation} from '@material/base'; -import {getCorrectEventName} from '@material/animation'; import {cssClasses, strings, numbers} from './constants'; -import {animateWithClass, getNormalizedEventCoords} from './util'; +import {getNormalizedEventCoords} from './util'; const DEACTIVATION_ACTIVATION_PAIRS = { mouseup: 'mousedown', @@ -85,31 +84,35 @@ export default class MDCRippleFoundation extends MDCFoundation { activate: (e) => this.activate_(e), deactivate: (e) => this.deactivate_(e), focus: () => requestAnimationFrame( - () => this.adapter_.addClass(MDCRippleFoundation.cssClasses.BG_ACTIVE) + () => this.adapter_.addClass(MDCRippleFoundation.cssClasses.BG_FOCUSED) ), blur: () => requestAnimationFrame( - () => this.adapter_.removeClass(MDCRippleFoundation.cssClasses.BG_ACTIVE) + () => this.adapter_.removeClass(MDCRippleFoundation.cssClasses.BG_FOCUSED) ), }; - this.unboundedOpacityFadeTimer_ = 0; this.resizeHandler_ = () => this.layout(); - this.cancelBgBounded_ = () => {}; - this.cancelFgBounded_ = () => {}; - this.cancelFgUnbounded_ = () => {}; this.unboundedCoords_ = { left: 0, top: 0, }; this.fgScale_ = 0; + this.activationTimer_ = 0; + this.activationAnimationHasEnded_ = false; + this.activationTimerCallback_ = () => { + this.activationAnimationHasEnded_ = true; + this.runDeactivationUXLogicIfReady_(); + }; } defaultActivationState_() { return { isActivated: false, + hasDeactivationUXRun: false, wasActivatedByPointer: false, wasElementMadeActive: false, activationStartTime: 0, activationEvent: null, + isProgrammatic: false, }; } @@ -150,8 +153,8 @@ export default class MDCRippleFoundation extends MDCFoundation { activationState.wasActivatedByPointer = activationState.isProgrammatic ? false : ( e.type === 'mousedown' || e.type === 'touchstart' || e.type === 'pointerdown' ); - activationState.activationStartTime = Date.now(); + requestAnimationFrame(() => { // This needs to be wrapped in an rAF call b/c web browsers // report active states inconsistently when they're called within @@ -173,34 +176,83 @@ export default class MDCRippleFoundation extends MDCFoundation { } animateActivation_() { + const {VAR_FG_TRANSLATE_START, VAR_FG_TRANSLATE_END} = MDCRippleFoundation.strings; const { - BG_ACTIVE, BG_BOUNDED_ACTIVE_FILL, - FG_UNBOUNDED_DEACTIVATION, FG_BOUNDED_ACTIVE_FILL, + BG_ACTIVE_FILL, + FG_DEACTIVATION, + FG_ACTIVATION, } = MDCRippleFoundation.cssClasses; + const {DEACTIVATION_TIMEOUT_MS} = MDCRippleFoundation.numbers; + + let translateStart = ''; + let translateEnd = ''; - // If ripple is currently deactivating, cancel those animations. - [ - BG_BOUNDED_ACTIVE_FILL, - FG_UNBOUNDED_DEACTIVATION, - FG_BOUNDED_ACTIVE_FILL, - ].forEach((c) => this.adapter_.removeClass(c)); - this.cancelBgBounded_(); - this.cancelFgBounded_(); - this.cancelFgUnbounded_(); - if (this.unboundedOpacityFadeTimer_) { - clearTimeout(this.unboundedOpacityFadeTimer_); - this.unboundedOpacityFadeTimer_ = 0; + if (!this.adapter_.isUnbounded()) { + const {startPoint, endPoint} = this.getFgTranslationCoordinates_(); + translateStart = `${startPoint.x}px, ${startPoint.y}px`; + translateEnd = `${endPoint.x}px, ${endPoint.y}px`; } - this.adapter_.addClass(BG_ACTIVE); - if (this.adapter_.isUnbounded()) { - this.animateUnboundedActivation_(); + this.adapter_.updateCssVariable(VAR_FG_TRANSLATE_START, translateStart); + this.adapter_.updateCssVariable(VAR_FG_TRANSLATE_END, translateEnd); + // Cancel any ongoing activation/deactivation animations + clearTimeout(this.activationTimer_); + this.rmBoundedActivationClasses_(); + this.adapter_.removeClass(FG_DEACTIVATION); + + // Force layout in order to re-trigger the animation. + this.adapter_.computeBoundingRect(); + this.adapter_.addClass(BG_ACTIVE_FILL); + this.adapter_.addClass(FG_ACTIVATION); + this.activationTimer_ = setTimeout(() => this.activationTimerCallback_(), DEACTIVATION_TIMEOUT_MS); + } + + getFgTranslationCoordinates_() { + const {activationState_: activationState} = this; + const {activationEvent, wasActivatedByPointer} = activationState; + + let startPoint; + if (wasActivatedByPointer) { + startPoint = getNormalizedEventCoords( + activationEvent, this.adapter_.getWindowPageOffset(), this.adapter_.computeBoundingRect() + ); + } else { + startPoint = { + x: this.frame_.width / 2, + y: this.frame_.height / 2, + }; + } + // Center the element around the start point. + startPoint = { + x: startPoint.x - (this.initialSize_ / 2), + y: startPoint.y - (this.initialSize_ / 2), + }; + + const endPoint = { + x: (this.frame_.width / 2) - (this.initialSize_ / 2), + y: (this.frame_.height / 2) - (this.initialSize_ / 2), + }; + + return {startPoint, endPoint}; + } + + runDeactivationUXLogicIfReady_() { + const {FG_DEACTIVATION} = MDCRippleFoundation.cssClasses; + const {hasDeactivationUXRun, isActivated} = this.activationState_; + const activationHasEnded = hasDeactivationUXRun || !isActivated; + if (activationHasEnded && this.activationAnimationHasEnded_) { + this.rmBoundedActivationClasses_(); + // Note that we don't need to remove this here since it's removed on re-activation. + this.adapter_.addClass(FG_DEACTIVATION); } } - animateUnboundedActivation_() { - const {FG_UNBOUNDED_ACTIVATION} = MDCRippleFoundation.cssClasses; - this.adapter_.addClass(FG_UNBOUNDED_ACTIVATION); + rmBoundedActivationClasses_() { + const {BG_ACTIVE_FILL, FG_ACTIVATION} = MDCRippleFoundation.cssClasses; + this.adapter_.removeClass(BG_ACTIVE_FILL); + this.adapter_.removeClass(FG_ACTIVATION); + this.activationAnimationHasEnded_ = false; + this.adapter_.computeBoundingRect(); } deactivate_(e) { @@ -211,10 +263,12 @@ export default class MDCRippleFoundation extends MDCFoundation { } // Programmatic deactivation. if (activationState.isProgrammatic) { - requestAnimationFrame(() => this.animateDeactivation_(null, Object.assign({}, activationState))); + const evtObject = null; + requestAnimationFrame(() => this.animateDeactivation_(evtObject, Object.assign({}, activationState))); this.activationState_ = this.defaultActivationState_(); return; } + const actualActivationType = DEACTIVATION_ACTIVATION_PAIRS[e.type]; const expectedActivationType = activationState.activationEvent.type; // NOTE: Pointer events are tricky - https://patrickhlauke.github.io/touch/tests/results/ @@ -228,113 +282,31 @@ export default class MDCRippleFoundation extends MDCFoundation { } const state = Object.assign({}, activationState); - if (needsDeactivationUX) { - requestAnimationFrame(() => this.animateDeactivation_(e, state)); - } - if (needsActualDeactivation) { - this.activationState_ = this.defaultActivationState_(); - } + requestAnimationFrame(() => { + if (needsDeactivationUX) { + this.activationState_.hasDeactivationUXRun = true; + this.animateDeactivation_(e, state); + } + + if (needsActualDeactivation) { + this.activationState_ = this.defaultActivationState_(); + } + }); } deactivate() { this.deactivate_(null); } - animateDeactivation_(e, {wasActivatedByPointer, wasElementMadeActive, activationStartTime, isProgrammatic}) { - const {BG_ACTIVE} = MDCRippleFoundation.cssClasses; + animateDeactivation_(e, {wasActivatedByPointer, wasElementMadeActive}) { + const {BG_FOCUSED} = MDCRippleFoundation.cssClasses; if (wasActivatedByPointer || wasElementMadeActive) { - this.adapter_.removeClass(BG_ACTIVE); - const isPointerEvent = isProgrammatic ? false : ( - e.type === 'touchend' || e.type === 'pointerup' || e.type === 'mouseup' - ); - if (this.adapter_.isUnbounded()) { - this.animateUnboundedDeactivation_(this.getUnboundedDeactivationInfo_(activationStartTime)); - } else { - this.animateBoundedDeactivation_(e, isPointerEvent); - } + // Remove class left over by element being focused + this.adapter_.removeClass(BG_FOCUSED); + this.runDeactivationUXLogicIfReady_(); } } - animateUnboundedDeactivation_({opacityDuration, transformDuration, approxCurScale}) { - const { - FG_UNBOUNDED_ACTIVATION, - FG_UNBOUNDED_DEACTIVATION, - } = MDCRippleFoundation.cssClasses; - const { - VAR_FG_UNBOUNDED_OPACITY_DURATION, - VAR_FG_UNBOUNDED_TRANSFORM_DURATION, - VAR_FG_APPROX_XF, - } = MDCRippleFoundation.strings; - - this.adapter_.updateCssVariable(VAR_FG_APPROX_XF, `scale(${approxCurScale})`); - this.adapter_.updateCssVariable(VAR_FG_UNBOUNDED_OPACITY_DURATION, `${opacityDuration}ms`); - this.adapter_.updateCssVariable(VAR_FG_UNBOUNDED_TRANSFORM_DURATION, `${transformDuration}ms`); - this.adapter_.addClass(FG_UNBOUNDED_DEACTIVATION); - this.adapter_.removeClass(FG_UNBOUNDED_ACTIVATION); - // We use setTimeout here since we know how long the fade will take. - this.unboundedOpacityFadeTimer_ = setTimeout(() => { - this.adapter_.removeClass(FG_UNBOUNDED_DEACTIVATION); - }, opacityDuration); - } - - getUnboundedDeactivationInfo_(activationStartTime) { - const msElapsed = Date.now() - activationStartTime; - const { - FG_TRANSFORM_DELAY_MS, OPACITY_DURATION_DIVISOR, - ACTIVE_OPACITY_DURATION_MS, UNBOUNDED_TRANSFORM_DURATION_MS, - MIN_OPACITY_DURATION_MS, - } = MDCRippleFoundation.numbers; - - let approxCurScale = 0; - if (msElapsed > FG_TRANSFORM_DELAY_MS) { - const percentComplete = Math.min((msElapsed - FG_TRANSFORM_DELAY_MS) / this.xfDuration_, 1); - approxCurScale = percentComplete * this.fgScale_; - } - - const transformDuration = UNBOUNDED_TRANSFORM_DURATION_MS; - const approxOpacity = Math.min(msElapsed / ACTIVE_OPACITY_DURATION_MS, 1); - const opacityDuration = Math.max( - MIN_OPACITY_DURATION_MS, 1000 * approxOpacity / OPACITY_DURATION_DIVISOR - ); - - return {transformDuration, opacityDuration, approxCurScale}; - } - - animateBoundedDeactivation_(e, isPointerEvent) { - let startPoint; - if (isPointerEvent) { - startPoint = getNormalizedEventCoords( - e, this.adapter_.getWindowPageOffset(), this.adapter_.computeBoundingRect() - ); - } else { - startPoint = { - x: this.frame_.width / 2, - y: this.frame_.height / 2, - }; - } - - startPoint = { - x: startPoint.x - (this.initialSize_ / 2), - y: startPoint.y - (this.initialSize_ / 2), - }; - - const endPoint = { - x: (this.frame_.width / 2) - (this.initialSize_ / 2), - y: (this.frame_.height / 2) - (this.initialSize_ / 2), - }; - - const {VAR_FG_TRANSLATE_START, VAR_FG_TRANSLATE_END} = MDCRippleFoundation.strings; - const {BG_BOUNDED_ACTIVE_FILL, FG_BOUNDED_ACTIVE_FILL} = MDCRippleFoundation.cssClasses; - this.adapter_.updateCssVariable(VAR_FG_TRANSLATE_START, `${startPoint.x}px, ${startPoint.y}px`); - this.adapter_.updateCssVariable(VAR_FG_TRANSLATE_END, `${endPoint.x}px, ${endPoint.y}px`); - this.cancelBgBounded_ = animateWithClass(this.adapter_, - BG_BOUNDED_ACTIVE_FILL, - getCorrectEventName(window, 'transitionend')); - this.cancelFgBounded_ = animateWithClass(this.adapter_, - FG_BOUNDED_ACTIVE_FILL, - getCorrectEventName(window, 'animationend')); - } - destroy() { if (!this.isSupported_) { return; @@ -396,13 +368,12 @@ export default class MDCRippleFoundation extends MDCFoundation { updateLayoutCssVars_() { const { VAR_SURFACE_WIDTH, VAR_SURFACE_HEIGHT, VAR_FG_SIZE, - VAR_FG_UNBOUNDED_TRANSFORM_DURATION, VAR_LEFT, VAR_TOP, VAR_FG_SCALE, + VAR_LEFT, VAR_TOP, VAR_FG_SCALE, } = MDCRippleFoundation.strings; this.adapter_.updateCssVariable(VAR_SURFACE_WIDTH, `${this.frame_.width}px`); this.adapter_.updateCssVariable(VAR_SURFACE_HEIGHT, `${this.frame_.height}px`); this.adapter_.updateCssVariable(VAR_FG_SIZE, `${this.initialSize_}px`); - this.adapter_.updateCssVariable(VAR_FG_UNBOUNDED_TRANSFORM_DURATION, `${this.xfDuration_}ms`); this.adapter_.updateCssVariable(VAR_FG_SCALE, this.fgScale_); if (this.adapter_.isUnbounded()) { diff --git a/packages/mdc-ripple/package.json b/packages/mdc-ripple/package.json index 311c427a1b2..2ac845857e3 100644 --- a/packages/mdc-ripple/package.json +++ b/packages/mdc-ripple/package.json @@ -14,7 +14,6 @@ "url": "https://github.com/material-components/material-components-web.git" }, "dependencies": { - "@material/animation": "^0.1.4", "@material/base": "^0.1.2", "@material/theme": "^0.1.2" } diff --git a/packages/mdc-ripple/util.js b/packages/mdc-ripple/util.js index 844cda16476..42b6b817cc8 100644 --- a/packages/mdc-ripple/util.js +++ b/packages/mdc-ripple/util.js @@ -36,21 +36,6 @@ export function getMatchesProperty(HTMLElementPrototype) { ].filter((p) => p in HTMLElementPrototype).pop(); } -export function animateWithClass(rippleAdapter, cls, endEvent) { - let cancelled = false; - const cancel = () => { - if (cancelled) { - return; - } - cancelled = true; - rippleAdapter.removeClass(cls); - rippleAdapter.deregisterInteractionHandler(endEvent, cancel); - }; - rippleAdapter.registerInteractionHandler(endEvent, cancel); - rippleAdapter.addClass(cls); - return cancel; -} - export function getNormalizedEventCoords(ev, pageOffset, clientRect) { const {x, y} = pageOffset; const documentX = x + clientRect.left; @@ -59,7 +44,7 @@ export function getNormalizedEventCoords(ev, pageOffset, clientRect) { let normalizedX; let normalizedY; // Determine touch point relative to the ripple container. - if (ev.type === 'touchend') { + if (ev.type === 'touchstart') { normalizedX = ev.changedTouches[0].pageX - documentX; normalizedY = ev.changedTouches[0].pageY - documentY; } else { diff --git a/test/unit/mdc-checkbox/mdc-checkbox.test.js b/test/unit/mdc-checkbox/mdc-checkbox.test.js index 2cde3fea75e..5b20d1976d9 100644 --- a/test/unit/mdc-checkbox/mdc-checkbox.test.js +++ b/test/unit/mdc-checkbox/mdc-checkbox.test.js @@ -86,11 +86,11 @@ if (supportsCssVariables(window)) { td.when(fakeMatches(':active')).thenReturn(true); input[getMatchesProperty(HTMLElement.prototype)] = fakeMatches; - assert.isOk(root.classList.contains('mdc-ripple-upgraded')); + assert.isTrue(root.classList.contains('mdc-ripple-upgraded')); domEvents.emit(input, 'keydown'); raf.flush(); - assert.isOk(root.classList.contains('mdc-ripple-upgraded--background-active')); + assert.isTrue(root.classList.contains('mdc-ripple-upgraded--foreground-activation')); raf.restore(); }); } diff --git a/test/unit/mdc-ripple/foundation-activation.test.js b/test/unit/mdc-ripple/foundation-activation.test.js index 5863c2b6c5d..1be61071fdd 100644 --- a/test/unit/mdc-ripple/foundation-activation.test.js +++ b/test/unit/mdc-ripple/foundation-activation.test.js @@ -17,41 +17,149 @@ import td from 'testdouble'; import {testFoundation, captureHandlers} from './helpers'; -import {cssClasses} from '../../../packages/mdc-ripple/constants'; +import {cssClasses, strings, numbers} from '../../../packages/mdc-ripple/constants'; suite('MDCRippleFoundation - Activation Logic'); -testFoundation(`adds ${cssClasses.BG_ACTIVE} on mousedown`, ({foundation, adapter, mockRaf}) => { +testFoundation('adds activation classes on mousedown', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); foundation.init(); mockRaf.flush(); handlers.mousedown(); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE)); + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL)); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION)); }); -testFoundation(`adds ${cssClasses.BG_ACTIVE} on touchstart`, ({foundation, adapter, mockRaf}) => { +testFoundation('sets FG position from the coords to the center within surface on mousedown', + ({foundation, adapter, mockRaf}) => { + const handlers = captureHandlers(adapter); + const left = 50; + const top = 50; + const width = 200; + const height = 100; + const maxSize = Math.max(width, height); + const initialSize = maxSize * numbers.INITIAL_ORIGIN_SCALE; + const pageX = 100; + const pageY = 75; + + td.when(adapter.computeBoundingRect()).thenReturn({width, height, left, top}); + foundation.init(); + mockRaf.flush(); + + handlers.mousedown({pageX, pageY}); + mockRaf.flush(); + + const startPosition = { + x: pageX - left - (initialSize / 2), + y: pageY - top - (initialSize / 2), + }; + + const endPosition = { + x: (width / 2) - (initialSize / 2), + y: (height / 2) - (initialSize / 2), + }; + + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, + `${startPosition.x}px, ${startPosition.y}px`)); + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, + `${endPosition.x}px, ${endPosition.y}px`)); +}); + +testFoundation('adds activation classes on touchstart', ({foundation, adapter, mockRaf}) => { + const handlers = captureHandlers(adapter); + foundation.init(); + mockRaf.flush(); + + handlers.touchstart({changedTouches: [{pageX: 0, pageY: 0}]}); + mockRaf.flush(); + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL)); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION)); +}); + +testFoundation('sets FG position from the coords to the center within surface on touchstart', + ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); + const left = 50; + const top = 50; + const width = 200; + const height = 100; + const maxSize = Math.max(width, height); + const initialSize = maxSize * numbers.INITIAL_ORIGIN_SCALE; + const pageX = 100; + const pageY = 75; + + td.when(adapter.computeBoundingRect()).thenReturn({width, height, left, top}); foundation.init(); mockRaf.flush(); - handlers.touchstart(); + handlers.touchstart({changedTouches: [{pageX, pageY}]}); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE)); + + const startPosition = { + x: pageX - left - (initialSize / 2), + y: pageY - top - (initialSize / 2), + }; + + const endPosition = { + x: (width / 2) - (initialSize / 2), + y: (height / 2) - (initialSize / 2), + }; + + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, + `${startPosition.x}px, ${startPosition.y}px`)); + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, + `${endPosition.x}px, ${endPosition.y}px`)); }); -testFoundation(`adds ${cssClasses.BG_ACTIVE} on pointerdown`, ({foundation, adapter, mockRaf}) => { +testFoundation('adds activation classes on pointerdown', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); foundation.init(); mockRaf.flush(); handlers.pointerdown(); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE)); + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL)); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION)); +}); + +testFoundation('sets FG position from the coords to the center within surface on pointerdown', + ({foundation, adapter, mockRaf}) => { + const handlers = captureHandlers(adapter); + const left = 50; + const top = 50; + const width = 200; + const height = 100; + const maxSize = Math.max(width, height); + const initialSize = maxSize * numbers.INITIAL_ORIGIN_SCALE; + const pageX = 100; + const pageY = 75; + + td.when(adapter.computeBoundingRect()).thenReturn({width, height, left, top}); + foundation.init(); + mockRaf.flush(); + + handlers.pointerdown({pageX, pageY}); + mockRaf.flush(); + + const startPosition = { + x: pageX - left - (initialSize / 2), + y: pageY - top - (initialSize / 2), + }; + + const endPosition = { + x: (width / 2) - (initialSize / 2), + y: (height / 2) - (initialSize / 2), + }; + + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, + `${startPosition.x}px, ${startPosition.y}px`)); + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, + `${endPosition.x}px, ${endPosition.y}px`)); }); -testFoundation(`adds ${cssClasses.BG_ACTIVE} on keydown when surface is made active`, +testFoundation('adds activation classes on keydown when surface is made active', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); td.when(adapter.isSurfaceActive()).thenReturn(true); @@ -61,10 +169,39 @@ testFoundation(`adds ${cssClasses.BG_ACTIVE} on keydown when surface is made act handlers.keydown(); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE)); + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL)); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION)); }); -testFoundation(`adds ${cssClasses.BG_ACTIVE} on public activate() call`, ({foundation, adapter, mockRaf}) => { +testFoundation('sets FG position to center on non-pointer activation', ({foundation, adapter, mockRaf}) => { + const handlers = captureHandlers(adapter); + const left = 50; + const top = 50; + const width = 200; + const height = 100; + const maxSize = Math.max(width, height); + const initialSize = maxSize * numbers.INITIAL_ORIGIN_SCALE; + + td.when(adapter.computeBoundingRect()).thenReturn({width, height, left, top}); + td.when(adapter.isSurfaceActive()).thenReturn(true); + foundation.init(); + mockRaf.flush(); + + handlers.keydown(); + mockRaf.flush(); + + const position = { + x: (width / 2) - (initialSize / 2), + y: (height / 2) - (initialSize / 2), + }; + + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, + `${position.x}px, ${position.y}px`)); + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, + `${position.x}px, ${position.y}px`)); +}); + +testFoundation('adds activation classes on programmatic activation', ({foundation, adapter, mockRaf}) => { td.when(adapter.isSurfaceActive()).thenReturn(true); foundation.init(); mockRaf.flush(); @@ -72,7 +209,36 @@ testFoundation(`adds ${cssClasses.BG_ACTIVE} on public activate() call`, ({found foundation.activate(); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE)); + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL)); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION)); +}); + +testFoundation('sets FG position to center on non-pointer activation', ({foundation, adapter, mockRaf}) => { + const handlers = captureHandlers(adapter); + const left = 50; + const top = 50; + const width = 200; + const height = 100; + const maxSize = Math.max(width, height); + const initialSize = maxSize * numbers.INITIAL_ORIGIN_SCALE; + + td.when(adapter.computeBoundingRect()).thenReturn({width, height, left, top}); + td.when(adapter.isSurfaceActive()).thenReturn(true); + foundation.init(); + mockRaf.flush(); + + handlers.keydown(); + mockRaf.flush(); + + const position = { + x: (width / 2) - (initialSize / 2), + y: (height / 2) - (initialSize / 2), + }; + + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, + `${position.x}px, ${position.y}px`)); + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, + `${position.x}px, ${position.y}px`)); }); testFoundation('does not redundantly add classes on touchstart followed by mousedown', @@ -81,11 +247,12 @@ testFoundation('does not redundantly add classes on touchstart followed by mouse foundation.init(); mockRaf.flush(); - handlers.touchstart(); + handlers.touchstart({changedTouches: [{pageX: 0, pageY: 0}]}); mockRaf.flush(); handlers.mousedown(); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE), {times: 1}); + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL), {times: 1}); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION), {times: 1}); }); testFoundation('does not redundantly add classes on touchstart followed by pointerstart', @@ -94,11 +261,12 @@ testFoundation('does not redundantly add classes on touchstart followed by point foundation.init(); mockRaf.flush(); - handlers.touchstart(); + handlers.touchstart({changedTouches: [{pageX: 0, pageY: 0}]}); mockRaf.flush(); handlers.pointerdown(); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE), {times: 1}); + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL), {times: 1}); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION), {times: 1}); }); testFoundation('removes deactivation classes on activate to ensure ripples can be retriggered', @@ -114,9 +282,7 @@ testFoundation('removes deactivation classes on activate to ensure ripples can b handlers.mousedown(); mockRaf.flush(); - td.verify(adapter.removeClass(cssClasses.BG_BOUNDED_ACTIVE_FILL)); - td.verify(adapter.removeClass(cssClasses.FG_UNBOUNDED_DEACTIVATION)); - td.verify(adapter.removeClass(cssClasses.FG_BOUNDED_ACTIVE_FILL)); + td.verify(adapter.removeClass(cssClasses.FG_DEACTIVATION)); }); testFoundation('displays the foreground ripple on activation when unbounded', ({foundation, adapter, mockRaf}) => { @@ -129,5 +295,20 @@ testFoundation('displays the foreground ripple on activation when unbounded', ({ handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.FG_UNBOUNDED_ACTIVATION)); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION)); +}); + +testFoundation('clears translation custom properties when unbounded in case ripple was switched from bounded', + ({foundation, adapter, mockRaf}) => { + const handlers = captureHandlers(adapter); + + td.when(adapter.isUnbounded()).thenReturn(true); + foundation.init(); + mockRaf.flush(); + + handlers.pointerdown({pageX: 100, pageY: 75}); + mockRaf.flush(); + + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, '')); + td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, '')); }); diff --git a/test/unit/mdc-ripple/foundation-deactivation.test.js b/test/unit/mdc-ripple/foundation-deactivation.test.js index 7a288a8bd92..bfa7dfbcfe0 100644 --- a/test/unit/mdc-ripple/foundation-deactivation.test.js +++ b/test/unit/mdc-ripple/foundation-deactivation.test.js @@ -14,383 +14,322 @@ * limitations under the License. */ -import td from 'testdouble'; import lolex from 'lolex'; +import td from 'testdouble'; import {testFoundation, captureHandlers} from './helpers'; -import {cssClasses, strings, numbers} from '../../../packages/mdc-ripple/constants'; -import {getCorrectEventName} from '../../../packages/mdc-animation'; - -const windowObj = td.object({ - document: { - createElement: (str) => ({ - style: { - animation: 'none', - transition: 'none', - }, - }), - }, -}); +import {cssClasses, numbers} from '../../../packages/mdc-ripple/constants'; + +const {DEACTIVATION_TIMEOUT_MS} = numbers; suite('MDCRippleFoundation - Deactivation logic'); testFoundation('runs deactivation UX on touchend after touchstart', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); + const clock = lolex.install(); foundation.init(); mockRaf.flush(); - handlers.touchstart(); - mockRaf.flush(); - handlers.touchend({changedTouches: [{pageX: 0, pageY: 0}]}); + handlers.touchstart({changedTouches: [{pageX: 0, pageY: 0}]}); mockRaf.flush(); - td.verify(adapter.removeClass(cssClasses.BG_ACTIVE)); - td.verify(adapter.addClass(cssClasses.BG_BOUNDED_ACTIVE_FILL)); - td.verify(adapter.addClass(cssClasses.FG_BOUNDED_ACTIVE_FILL)); - - // Test removal of classes on end event - handlers[getCorrectEventName(windowObj, 'transitionend')](); - mockRaf.flush(); - td.verify(adapter.removeClass(cssClasses.BG_BOUNDED_ACTIVE_FILL), {times: 2}); - handlers[getCorrectEventName(windowObj, 'animationend')](); + handlers.touchend(); mockRaf.flush(); - td.verify(adapter.removeClass(cssClasses.FG_BOUNDED_ACTIVE_FILL), {times: 2}); + clock.tick(DEACTIVATION_TIMEOUT_MS); + + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED)); + // NOTE: here and below, we use {times: 2} as these classes are removed during activation + // as well in order to support re-triggering the ripple. We want to test that this is called a *second* + // time when deactivating. + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION)); + clock.uninstall(); }); testFoundation('runs deactivation UX on pointerup after pointerdown', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); + const clock = lolex.install(); foundation.init(); mockRaf.flush(); - handlers.pointerdown(); + handlers.pointerdown({pageX: 0, pageY: 0}); mockRaf.flush(); - handlers.pointerup({pageX: 0, pageY: 0}); + + handlers.pointerup(); mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS); - td.verify(adapter.removeClass(cssClasses.BG_ACTIVE)); - td.verify(adapter.addClass(cssClasses.BG_BOUNDED_ACTIVE_FILL)); - td.verify(adapter.addClass(cssClasses.FG_BOUNDED_ACTIVE_FILL)); + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED)); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION)); + clock.uninstall(); }); testFoundation('runs deactivation UX on mouseup after mousedown', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); + const clock = lolex.install(); foundation.init(); mockRaf.flush(); - handlers.mousedown(); + handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - handlers.mouseup({pageX: 0, pageY: 0}); + + handlers.mouseup(); mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS); - td.verify(adapter.removeClass(cssClasses.BG_ACTIVE)); - td.verify(adapter.addClass(cssClasses.BG_BOUNDED_ACTIVE_FILL)); - td.verify(adapter.addClass(cssClasses.FG_BOUNDED_ACTIVE_FILL)); + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED)); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION)); + clock.uninstall(); }); -testFoundation('runs deactivation UX on public deactivate() call', ({foundation, adapter, mockRaf}) => { +testFoundation('runs deactivation on keyup after keydown when keydown makes surface active', + ({foundation, adapter, mockRaf}) => { + const handlers = captureHandlers(adapter); + const clock = lolex.install(); + td.when(adapter.isSurfaceActive()).thenReturn(true); + foundation.init(); mockRaf.flush(); - foundation.activate(); + handlers.keydown({key: 'Space'}); mockRaf.flush(); - foundation.deactivate(); + + handlers.keyup({key: 'Space'}); mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS); - td.verify(adapter.removeClass(cssClasses.BG_ACTIVE)); - td.verify(adapter.addClass(cssClasses.BG_BOUNDED_ACTIVE_FILL)); - td.verify(adapter.addClass(cssClasses.FG_BOUNDED_ACTIVE_FILL)); + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED)); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION)); + clock.uninstall(); }); -testFoundation('only re-activates when there are no additional pointer events to be processed', +testFoundation('does not run deactivation on keyup after keydown if keydown did not make surface active', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); + const clock = lolex.install(); + td.when(adapter.isSurfaceActive()).thenReturn(false); + foundation.init(); mockRaf.flush(); - // Simulate Android 6 / Chrome latest event flow. - handlers.pointerdown(); - mockRaf.flush(); - handlers.touchstart(); + handlers.keydown({key: 'Space'}); mockRaf.flush(); - handlers.pointerup({pageX: 0, pageY: 0}); + handlers.keyup({key: 'Space'}); mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS); - // At this point, the deactivation UX should have run, since the initial activation was triggered by - // a pointerdown event. - td.verify(adapter.removeClass(cssClasses.BG_ACTIVE), {times: 1}); - td.verify(adapter.addClass(cssClasses.BG_BOUNDED_ACTIVE_FILL), {times: 1}); - td.verify(adapter.addClass(cssClasses.FG_BOUNDED_ACTIVE_FILL), {times: 1}); - - handlers.touchend({changedTouches: [{pageX: 0, pageY: 0}]}); - mockRaf.flush(); + // Note that all of these should be called 0 times since a keydown that does not make a surface active should never + // activate it in the first place. + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED), {times: 0}); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 0}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 0}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION), {times: 0}); + clock.uninstall(); +}); - // Verify that deactivation UX has not been run redundantly - td.verify(adapter.removeClass(cssClasses.BG_ACTIVE), {times: 1}); - td.verify(adapter.addClass(cssClasses.BG_BOUNDED_ACTIVE_FILL), {times: 1}); - td.verify(adapter.addClass(cssClasses.FG_BOUNDED_ACTIVE_FILL), {times: 1}); +testFoundation('runs deactivation UX on public deactivate() call', ({foundation, adapter, mockRaf}) => { + const clock = lolex.install(); - handlers.mousedown(); + foundation.init(); mockRaf.flush(); - // Verify that activation only happened once, at pointerdown - td.verify(adapter.addClass(cssClasses.BG_ACTIVE), {times: 1}); - - handlers.mouseup({pageX: 0, pageY: 0}); + foundation.activate(); mockRaf.flush(); - // Finally, verify that since mouseup happened, we can re-activate the ripple. - handlers.mousedown(); + foundation.deactivate(); mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE), {times: 2}); + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED)); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION)); + clock.uninstall(); }); -testFoundation('sets FG position from the coords to the center within surface on pointer deactivation', +testFoundation('runs deactivation UX when activation UX timer finishes first (activation held for a long time)', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); - const left = 50; - const top = 50; - const width = 200; - const height = 100; - const maxSize = Math.max(width, height); - const initialSize = maxSize * numbers.INITIAL_ORIGIN_SCALE; - const pageX = 100; - const pageY = 75; - - td.when(adapter.computeBoundingRect()).thenReturn({width, height, left, top}); + const clock = lolex.install(); foundation.init(); mockRaf.flush(); - handlers.mousedown(); - mockRaf.flush(); - handlers.mouseup({pageX, pageY}); + handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - const startPosition = { - x: pageX - left - (initialSize / 2), - y: pageY - top - (initialSize / 2), - }; - - const endPosition = { - x: (width / 2) - (initialSize / 2), - y: (height / 2) - (initialSize / 2), - }; + clock.tick(DEACTIVATION_TIMEOUT_MS); + handlers.mouseup(); + mockRaf.flush(); - td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, - `${startPosition.x}px, ${startPosition.y}px`)); - td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, - `${endPosition.x}px, ${endPosition.y}px`)); + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED)); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION)); + clock.uninstall(); }); -testFoundation('takes scroll offset into account when computing position', ({foundation, adapter, mockRaf}) => { +testFoundation('clears any pending deactivation UX timers when re-triggered', ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); - const left = 50; - const top = 50; - const width = 200; - const height = 100; - const x = 25; - const y = 25; - const maxSize = Math.max(width, height); - const initialSize = maxSize * numbers.INITIAL_ORIGIN_SCALE; - const pageX = 100; - const pageY = 75; - - td.when(adapter.computeBoundingRect()).thenReturn({width, height, left, top}); - td.when(adapter.getWindowPageOffset()).thenReturn({x, y}); + const clock = lolex.install(); foundation.init(); mockRaf.flush(); - handlers.mousedown(); - mockRaf.flush(); - handlers.mouseup({pageX, pageY}); + // Trigger the first interaction + handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - - const startPosition = { - x: pageX - left - x - (initialSize / 2), - y: pageY - top - y - (initialSize / 2), - }; - - const endPosition = { - x: (width / 2) - (initialSize / 2), - y: (height / 2) - (initialSize / 2), - }; - - td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, - `${startPosition.x}px, ${startPosition.y}px`)); - td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, - `${endPosition.x}px, ${endPosition.y}px`)); -}); - -testFoundation('sets unbounded FG position to center on non-pointer deactivation', ({foundation, adapter, mockRaf}) => { - const handlers = captureHandlers(adapter); - const left = 50; - const top = 50; - const width = 200; - const height = 100; - const maxSize = Math.max(width, height); - const initialSize = maxSize * numbers.INITIAL_ORIGIN_SCALE; - - td.when(adapter.computeBoundingRect()).thenReturn({width, height, left, top}); - td.when(adapter.isSurfaceActive()).thenReturn(true, false); - foundation.init(); + handlers.mouseup(); mockRaf.flush(); + // Simulate certain amount of delay between first and second interaction + clock.tick(20); - handlers.keydown(); + // Trigger the second interaction + handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - handlers.keyup(); + handlers.mouseup(); mockRaf.flush(); - - const position = { - x: (width / 2) - (initialSize / 2), - y: (height / 2) - (initialSize / 2), - }; - - td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_START, - `${position.x}px, ${position.y}px`)); - td.verify(adapter.updateCssVariable(strings.VAR_FG_TRANSLATE_END, - `${position.x}px, ${position.y}px`)); + clock.tick(DEACTIVATION_TIMEOUT_MS); + + // Verify that BG_FOCUSED was removed both times + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED), {times: 2}); + // Verify that deactivation timer was called 3 times: + // - Once during the initial activation + // - Once again during the second activation when the ripple was re-triggered + // - A third and final time when the deactivation UX timer runs + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 3}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 3}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION), {times: 1}); + clock.uninstall(); }); -testFoundation('triggers unbounded deactivation based on time it took to activate', +testFoundation('waits until activation UX timer runs before removing active fill classes', ({foundation, adapter, mockRaf}) => { - const clock = lolex.install(); const handlers = captureHandlers(adapter); - const size = 100; - td.when(adapter.isUnbounded()).thenReturn(true); - td.when(adapter.computeBoundingRect()).thenReturn({width: size, height: size, left: 0, top: 0}); + const clock = lolex.install(); + foundation.init(); mockRaf.flush(); handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - const baseElapsedTime = 20; - - clock.tick(baseElapsedTime + numbers.FG_TRANSFORM_DELAY_MS); - handlers.mouseup(); mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS - 1); - const surfaceDiameter = Math.sqrt(Math.pow(size, 2) + Math.pow(size, 2)); - const initialSize = size * numbers.INITIAL_ORIGIN_SCALE; - const maxRadius = surfaceDiameter + numbers.PADDING; - const fgScale = maxRadius / initialSize; - const xfDuration = 1000 * Math.sqrt(maxRadius / 1024); - - const scaleVal = baseElapsedTime / xfDuration * fgScale; - - - td.verify(adapter.updateCssVariable(strings.VAR_FG_APPROX_XF, `scale(${scaleVal})`)); - td.verify(adapter.updateCssVariable( - strings.VAR_FG_UNBOUNDED_TRANSFORM_DURATION, `${numbers.UNBOUNDED_TRANSFORM_DURATION_MS}ms` - )); - const opacity = ((baseElapsedTime + numbers.FG_TRANSFORM_DELAY_MS) / numbers.ACTIVE_OPACITY_DURATION_MS); - const opacityDuration = 1000 * opacity / numbers.OPACITY_DURATION_DIVISOR; - td.verify( - adapter.updateCssVariable(strings.VAR_FG_UNBOUNDED_OPACITY_DURATION, `${opacityDuration}ms`) - ); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 1}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 1}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION), {times: 0}); clock.uninstall(); }); -testFoundation('clamps opacity duration to minimum value for unbounded deactivation', +testFoundation('waits until actual deactivation UX is needed if animation finishes before deactivating', ({foundation, adapter, mockRaf}) => { - const clock = lolex.install(); const handlers = captureHandlers(adapter); - td.when(adapter.isUnbounded()).thenReturn(true); + const clock = lolex.install(); + foundation.init(); mockRaf.flush(); handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - clock.tick(10); - handlers.mouseup(); - mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS); - td.verify( - adapter.updateCssVariable(strings.VAR_FG_UNBOUNDED_OPACITY_DURATION, '200ms') - ); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 1}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 1}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION), {times: 0}); clock.uninstall(); }); -testFoundation('clamps opacity duration to max value for unbounded deactivation', ({foundation, adapter, mockRaf}) => { - const clock = lolex.install(); +testFoundation('removes BG_FOCUSED class immediately without waiting for animationend event', + ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); - td.when(adapter.isUnbounded()).thenReturn(true); + const clock = lolex.install(); + foundation.init(); mockRaf.flush(); handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - clock.tick(1000); + handlers.mouseup(); mockRaf.flush(); - const about333ms = td.matchers.argThat((duration) => { - const ms = parseFloat(duration); - return ms.toFixed(2) === '333.33'; - }); - td.verify( - adapter.updateCssVariable(strings.VAR_FG_UNBOUNDED_OPACITY_DURATION, about333ms) - ); + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED)); clock.uninstall(); }); -testFoundation('toggles unbounded activation classes', ({foundation, adapter, mockRaf}) => { - const clock = lolex.install(); +testFoundation('only re-activates when there are no additional pointer events to be processed', + ({foundation, adapter, mockRaf}) => { const handlers = captureHandlers(adapter); - td.when(adapter.isUnbounded()).thenReturn(true); + const clock = lolex.install(); foundation.init(); mockRaf.flush(); - handlers.mousedown({pageX: 0, pageY: 0}); + // Simulate Android 6 / Chrome latest event flow. + handlers.pointerdown({pageX: 0, pageY: 0}); mockRaf.flush(); - clock.tick(100); - handlers.mouseup(); + handlers.touchstart({changedTouches: [{pageX: 0, pageY: 0}]}); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.FG_UNBOUNDED_DEACTIVATION)); - td.verify(adapter.removeClass(cssClasses.FG_UNBOUNDED_ACTIVATION)); - clock.tick(/* past opacity duration */300); - td.verify(adapter.removeClass(cssClasses.FG_UNBOUNDED_DEACTIVATION)); - clock.uninstall(); -}); + clock.tick(DEACTIVATION_TIMEOUT_MS); + handlers.pointerup(); + mockRaf.flush(); -testFoundation('cancels unbounded deactivation class removal on deactivation', ({foundation, adapter, mockRaf}) => { - const clock = lolex.install(); - const handlers = captureHandlers(adapter); - td.when(adapter.isUnbounded()).thenReturn(true); - foundation.init(); + // At this point, the deactivation UX should have run, since the initial activation was triggered by + // a pointerdown event. + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION), {times: 1}); + + handlers.touchend(); mockRaf.flush(); + // Verify that deactivation UX has not been run redundantly + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED), {times: 1}); + td.verify(adapter.removeClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.removeClass(cssClasses.FG_ACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_DEACTIVATION), {times: 1}); + handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - clock.tick(100); + + // Verify that activation only happened once, at pointerdown + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL), {times: 1}); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION), {times: 1}); + handlers.mouseup(); mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS); - handlers.mousedown(); + // Finally, verify that since mouseup happened, we can re-activate the ripple. + handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.flush(); - clock.tick(/* past opacity duration */300); - // Verify this is only called twice on both initial activations, but not as part of a deactivation timeout. - td.verify(adapter.removeClass(cssClasses.FG_UNBOUNDED_DEACTIVATION), {times: 2}); + td.verify(adapter.addClass(cssClasses.BG_ACTIVE_FILL), {times: 2}); + td.verify(adapter.addClass(cssClasses.FG_ACTIVATION), {times: 2}); clock.uninstall(); }); testFoundation('ensures pointer event deactivation occurs even if activation rAF not run', ({foundation, adapter, mockRaf}) => { - const clock = lolex.install(); const handlers = captureHandlers(adapter); - td.when(adapter.isUnbounded()).thenReturn(true); + const clock = lolex.install(); foundation.init(); mockRaf.flush(); handlers.mousedown({pageX: 0, pageY: 0}); mockRaf.pendingFrames.shift(); - clock.tick(100); handlers.mouseup(); mockRaf.flush(); + clock.tick(DEACTIVATION_TIMEOUT_MS); - td.verify(adapter.addClass(cssClasses.FG_UNBOUNDED_DEACTIVATION)); + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED), {times: 1}); clock.uninstall(); }); diff --git a/test/unit/mdc-ripple/foundation-general-events.test.js b/test/unit/mdc-ripple/foundation-general-events.test.js index a2efb139682..9bf2c68f361 100644 --- a/test/unit/mdc-ripple/foundation-general-events.test.js +++ b/test/unit/mdc-ripple/foundation-general-events.test.js @@ -94,7 +94,7 @@ testFoundation('activates the background on focus', ({foundation, adapter, mockR handlers.focus(); mockRaf.flush(); - td.verify(adapter.addClass(cssClasses.BG_ACTIVE)); + td.verify(adapter.addClass(cssClasses.BG_FOCUSED)); }); testFoundation('deactivates the background on blur', ({foundation, adapter, mockRaf}) => { @@ -104,5 +104,5 @@ testFoundation('deactivates the background on blur', ({foundation, adapter, mock handlers.blur(); mockRaf.flush(); - td.verify(adapter.removeClass(cssClasses.BG_ACTIVE)); + td.verify(adapter.removeClass(cssClasses.BG_FOCUSED)); }); diff --git a/test/unit/mdc-ripple/foundation.test.js b/test/unit/mdc-ripple/foundation.test.js index cc679ef6d26..810b6d5f1a0 100644 --- a/test/unit/mdc-ripple/foundation.test.js +++ b/test/unit/mdc-ripple/foundation.test.js @@ -17,6 +17,7 @@ import {assert} from 'chai'; import td from 'testdouble'; +import {verifyDefaultAdapter} from '../helpers/foundation'; import MDCRippleFoundation from '../../../packages/mdc-ripple/foundation'; import {cssClasses, strings, numbers} from '../../../packages/mdc-ripple/constants'; @@ -37,17 +38,11 @@ test('numbers returns constants.numbers', () => { }); test('defaultAdapter returns a complete adapter implementation', () => { - const {defaultAdapter} = MDCRippleFoundation; - const methods = Object.keys(defaultAdapter).filter((k) => typeof defaultAdapter[k] === 'function'); - - assert.equal(methods.length, Object.keys(defaultAdapter).length, 'Every adapter key must be a function'); - assert.deepEqual(methods, [ + verifyDefaultAdapter(MDCRippleFoundation, [ 'browserSupportsCssVars', 'isUnbounded', 'isSurfaceActive', 'addClass', 'removeClass', 'registerInteractionHandler', 'deregisterInteractionHandler', 'registerResizeHandler', 'deregisterResizeHandler', 'updateCssVariable', 'computeBoundingRect', 'getWindowPageOffset', ]); - // Test default methods - methods.forEach((m) => assert.doesNotThrow(defaultAdapter[m])); }); testFoundation(`#init calls adapter.addClass("${cssClasses.ROOT}")`, ({adapter, foundation, mockRaf}) => { @@ -110,21 +105,6 @@ testFoundation(`#init sets ${strings.VAR_FG_SIZE} to the circumscribing circle's td.verify(adapter.updateCssVariable(strings.VAR_FG_SIZE, `${initialSize}px`)); }); -testFoundation(`#init sets ${strings.VAR_FG_UNBOUNDED_TRANSFORM_DURATION} based on the max radius`, - ({foundation, adapter, mockRaf}) => { - const width = 200; - const height = 100; - td.when(adapter.computeBoundingRect()).thenReturn({width, height}); - foundation.init(); - mockRaf.flush(); - - const expectedDiameter = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)); - const expectedRadius = expectedDiameter + numbers.PADDING; - const expectedDuration = 1000 * Math.sqrt(expectedRadius / 1024); - const {VAR_FG_UNBOUNDED_TRANSFORM_DURATION: expectedCssVar} = strings; - td.verify(adapter.updateCssVariable(expectedCssVar, `${expectedDuration}ms`)); -}); - testFoundation(`#init centers via ${strings.VAR_LEFT} and ${strings.VAR_TOP} when unbounded`, ({foundation, adapter, mockRaf}) => { const width = 200; @@ -262,23 +242,6 @@ testFoundation(`#layout sets ${strings.VAR_FG_SCALE} based on the difference bet td.verify(adapter.updateCssVariable(strings.VAR_FG_SCALE, fgScale)); }); -testFoundation(`#layout sets ${strings.VAR_FG_UNBOUNDED_TRANSFORM_DURATION} based on the max radius`, - ({foundation, adapter, mockRaf}) => { - const width = 200; - const height = 100; - - td.when(adapter.computeBoundingRect()).thenReturn({width, height}); - foundation.layout(); - mockRaf.flush(); - - const expectedDiameter = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2)); - const expectedRadius = expectedDiameter + numbers.PADDING; - const expectedDuration = 1000 * Math.sqrt(expectedRadius / 1024); - - const {VAR_FG_UNBOUNDED_TRANSFORM_DURATION: expectedCssVar} = strings; - td.verify(adapter.updateCssVariable(expectedCssVar, `${expectedDuration}ms`)); -}); - testFoundation(`#layout centers via ${strings.VAR_LEFT} and ${strings.VAR_TOP} when unbounded`, ({foundation, adapter, mockRaf}) => { const width = 200; diff --git a/test/unit/mdc-ripple/helpers.js b/test/unit/mdc-ripple/helpers.js index 3abbf291ec4..df660d7da41 100644 --- a/test/unit/mdc-ripple/helpers.js +++ b/test/unit/mdc-ripple/helpers.js @@ -44,5 +44,6 @@ export function testFoundation(desc, isCssVarsSupported, runTests) { } export function captureHandlers(adapter) { - return baseCaptureHandlers(adapter, 'registerInteractionHandler'); + const handlers = baseCaptureHandlers(adapter, 'registerInteractionHandler'); + return handlers; } diff --git a/test/unit/mdc-ripple/util.test.js b/test/unit/mdc-ripple/util.test.js index e41ba29503d..e750f8fd302 100644 --- a/test/unit/mdc-ripple/util.test.js +++ b/test/unit/mdc-ripple/util.test.js @@ -85,88 +85,8 @@ test('#getMatchesProperty returns the standard function if more than one method assert.equal(util.getMatchesProperty({matches: () => {}, webkitMatchesSelector: () => {}}), 'matches'); }); -class FakeRippleAdapter { - constructor() { - this.addClass = td.func('.addClass'); - this.removeClass = td.func('.removeClass'); - this.eventType = ''; - this.interactionHandler = null; - } - registerInteractionHandler(type, handler) { - this.eventType = type; - this.interactionHandler = handler; - } - deregisterInteractionHandler(type, handler) { - if (type === this.eventType && handler === this.interactionHandler) { - this.eventType = ''; - this.interactionHandler = null; - } - } -} - -function setupAnimateWithClassTest() { - const adapter = new FakeRippleAdapter(); - const className = 'className'; - const endEvent = 'endEvent'; - return {adapter, className, endEvent}; -} - -test('#animateWithClass attaches class and handler which removes class once specified event is fired', () => { - const {adapter, className, endEvent} = setupAnimateWithClassTest(); - util.animateWithClass(adapter, className, endEvent); - td.verify(adapter.addClass(className)); - assert.equal(adapter.eventType, endEvent); - assert.isOk(typeof adapter.interactionHandler === 'function'); - - adapter.interactionHandler(); - - td.verify(adapter.removeClass(className)); -}); - -test('#animateWithClass removes the event listener it used for the end event', () => { - const {adapter, className, endEvent} = setupAnimateWithClassTest(); - util.animateWithClass(adapter, className, endEvent); - td.verify(adapter.addClass(className)); - assert.equal(adapter.eventType, endEvent); - assert.isOk(typeof adapter.interactionHandler === 'function'); - - adapter.interactionHandler(); - - assert.equal(adapter.eventType, ''); - assert.equal(adapter.interactionHandler, null); -}); - -test('#animateWithClass returns a function which allows you to manually remove class/unlisten', () => { - const {adapter, className, endEvent} = setupAnimateWithClassTest(); - const cancel = util.animateWithClass(adapter, className, endEvent); - - td.verify(adapter.addClass(className)); - assert.equal(adapter.eventType, endEvent, 'event registration sanity check (type)'); - assert.isOk(typeof adapter.interactionHandler === 'function', 'event registration sanity check (handler)'); - - cancel(); - - td.verify(adapter.removeClass(className)); - assert.equal(adapter.eventType, ''); - assert.equal(adapter.interactionHandler, null); -}); - -test('#animateWithClass return function can only be called once', () => { - const {adapter, className, endEvent} = setupAnimateWithClassTest(); - const cancel = util.animateWithClass(adapter, className, endEvent); - - td.verify(adapter.addClass(className)); - assert.equal(adapter.eventType, endEvent, 'event registration sanity check (type)'); - assert.isOk(typeof adapter.interactionHandler === 'function', 'event registration sanity check (handler)'); - - cancel(); - cancel(); - - td.verify(adapter.removeClass(className), {times: 1}); -}); - test('#getNormalizedEventCoords maps event coords into the relative coordinates of the given rect', () => { - const ev = {type: 'mouseup', pageX: 70, pageY: 70}; + const ev = {type: 'mousedown', pageX: 70, pageY: 70}; const pageOffset = {x: 10, y: 10}; const clientRect = {left: 50, top: 50}; @@ -176,8 +96,8 @@ test('#getNormalizedEventCoords maps event coords into the relative coordinates }); }); -test('#getNormalizedEventCoords works with touchend events', () => { - const ev = {type: 'touchend', changedTouches: [{pageX: 70, pageY: 70}]}; +test('#getNormalizedEventCoords works with touchstart events', () => { + const ev = {type: 'touchstart', changedTouches: [{pageX: 70, pageY: 70}]}; const pageOffset = {x: 10, y: 10}; const clientRect = {left: 50, top: 50};