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};