UNPKG

@smui/ripple

Version:

Svelte Material UI - Ripple

480 lines 20.5 kB
/** * @license * Copyright 2016 Google Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ import { MDCFoundation } from '@smui/base/foundation'; import { cssClasses, numbers, strings } from './constants'; import { getNormalizedEventCoords } from './util'; // Activation events registered on the root element of each instance for // activation const ACTIVATION_EVENT_TYPES = [ 'touchstart', 'pointerdown', 'mousedown', 'keydown', ]; // Deactivation events registered on documentElement when a pointer-related down // event occurs const POINTER_DEACTIVATION_EVENT_TYPES = [ 'touchend', 'pointerup', 'mouseup', 'contextmenu', ]; // simultaneous nested activations let activatedTargets = []; /** MDC Ripple Foundation */ export class MDCRippleFoundation extends MDCFoundation { static get cssClasses() { return cssClasses; } static get strings() { return strings; } static get numbers() { return numbers; } static get defaultAdapter() { return { addClass: () => undefined, browserSupportsCssVars: () => true, computeBoundingRect: () => ({ top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0 }), containsEventTarget: () => true, deregisterDocumentInteractionHandler: () => undefined, deregisterInteractionHandler: () => undefined, deregisterResizeHandler: () => undefined, getWindowPageOffset: () => ({ x: 0, y: 0 }), isSurfaceActive: () => true, isSurfaceDisabled: () => true, isUnbounded: () => true, registerDocumentInteractionHandler: () => undefined, registerInteractionHandler: () => undefined, registerResizeHandler: () => undefined, removeClass: () => undefined, updateCssVariable: () => undefined, }; } constructor(adapter) { super(Object.assign(Object.assign({}, MDCRippleFoundation.defaultAdapter), adapter)); this.activationAnimationHasEnded = false; this.activationTimer = 0; this.fgDeactivationRemovalTimer = 0; this.fgScale = '0'; this.frame = { width: 0, height: 0 }; this.initialSize = 0; this.layoutFrame = 0; this.maxRadius = 0; this.unboundedCoords = { left: 0, top: 0 }; this.activationState = this.defaultActivationState(); this.activationTimerCallback = () => { this.activationAnimationHasEnded = true; this.runDeactivationUXLogicIfReady(); }; this.activateHandler = (e) => { this.activateImpl(e); }; this.deactivateHandler = () => { this.deactivateImpl(); }; this.focusHandler = () => { this.handleFocus(); }; this.blurHandler = () => { this.handleBlur(); }; this.resizeHandler = () => { this.layout(); }; } init() { const supportsPressRipple = this.supportsPressRipple(); this.registerRootHandlers(supportsPressRipple); if (supportsPressRipple) { const { ROOT, UNBOUNDED } = MDCRippleFoundation.cssClasses; requestAnimationFrame(() => { this.adapter.addClass(ROOT); if (this.adapter.isUnbounded()) { this.adapter.addClass(UNBOUNDED); // Unbounded ripples need layout logic applied immediately to set // coordinates for both shade and ripple this.layoutInternal(); } }); } } destroy() { if (this.supportsPressRipple()) { if (this.activationTimer) { clearTimeout(this.activationTimer); this.activationTimer = 0; this.adapter.removeClass(MDCRippleFoundation.cssClasses.FG_ACTIVATION); } if (this.fgDeactivationRemovalTimer) { clearTimeout(this.fgDeactivationRemovalTimer); this.fgDeactivationRemovalTimer = 0; this.adapter.removeClass(MDCRippleFoundation.cssClasses.FG_DEACTIVATION); } const { ROOT, UNBOUNDED } = MDCRippleFoundation.cssClasses; requestAnimationFrame(() => { this.adapter.removeClass(ROOT); this.adapter.removeClass(UNBOUNDED); this.removeCssVars(); }); } this.deregisterRootHandlers(); this.deregisterDeactivationHandlers(); } /** * @param event Optional event containing position information. */ activate(event) { this.activateImpl(event); } deactivate() { this.deactivateImpl(); } layout() { if (this.layoutFrame) { cancelAnimationFrame(this.layoutFrame); } this.layoutFrame = requestAnimationFrame(() => { this.layoutInternal(); this.layoutFrame = 0; }); } setUnbounded(unbounded) { const { UNBOUNDED } = MDCRippleFoundation.cssClasses; if (unbounded) { this.adapter.addClass(UNBOUNDED); } else { this.adapter.removeClass(UNBOUNDED); } } handleFocus() { requestAnimationFrame(() => { this.adapter.addClass(MDCRippleFoundation.cssClasses.BG_FOCUSED); }); } handleBlur() { requestAnimationFrame(() => { this.adapter.removeClass(MDCRippleFoundation.cssClasses.BG_FOCUSED); }); } /** * We compute this property so that we are not querying information about the * client until the point in time where the foundation requests it. This * prevents scenarios where client-side feature-detection may happen too * early, such as when components are rendered on the server and then * initialized at mount time on the client. */ supportsPressRipple() { return this.adapter.browserSupportsCssVars(); } defaultActivationState() { return { activationEvent: undefined, hasDeactivationUXRun: false, isActivated: false, isProgrammatic: false, wasActivatedByPointer: false, wasElementMadeActive: false, }; } /** * supportsPressRipple Passed from init to save a redundant function call */ registerRootHandlers(supportsPressRipple) { if (supportsPressRipple) { for (const eventType of ACTIVATION_EVENT_TYPES) { this.adapter.registerInteractionHandler(eventType, this.activateHandler); } if (this.adapter.isUnbounded()) { this.adapter.registerResizeHandler(this.resizeHandler); } } this.adapter.registerInteractionHandler('focus', this.focusHandler); this.adapter.registerInteractionHandler('blur', this.blurHandler); } registerDeactivationHandlers(event) { if (event.type === 'keydown') { this.adapter.registerInteractionHandler('keyup', this.deactivateHandler); } else { for (const eventType of POINTER_DEACTIVATION_EVENT_TYPES) { this.adapter.registerDocumentInteractionHandler(eventType, this.deactivateHandler); } } } deregisterRootHandlers() { for (const eventType of ACTIVATION_EVENT_TYPES) { this.adapter.deregisterInteractionHandler(eventType, this.activateHandler); } this.adapter.deregisterInteractionHandler('focus', this.focusHandler); this.adapter.deregisterInteractionHandler('blur', this.blurHandler); if (this.adapter.isUnbounded()) { this.adapter.deregisterResizeHandler(this.resizeHandler); } } deregisterDeactivationHandlers() { this.adapter.deregisterInteractionHandler('keyup', this.deactivateHandler); for (const eventType of POINTER_DEACTIVATION_EVENT_TYPES) { this.adapter.deregisterDocumentInteractionHandler(eventType, this.deactivateHandler); } } removeCssVars() { const rippleStrings = MDCRippleFoundation.strings; const keys = Object.keys(rippleStrings); keys.forEach((key) => { if (key.indexOf('VAR_') === 0) { this.adapter.updateCssVariable(rippleStrings[key], null); } }); } activateImpl(event) { if (this.adapter.isSurfaceDisabled()) { return; } const activationState = this.activationState; if (activationState.isActivated) { return; } // Avoid reacting to follow-on events fired by touch device after an // already-processed user interaction const previousActivationEvent = this.previousActivationEvent; const isSameInteraction = previousActivationEvent && event !== undefined && previousActivationEvent.type !== event.type; if (isSameInteraction) { return; } activationState.isActivated = true; activationState.isProgrammatic = event === undefined; activationState.activationEvent = event; activationState.wasActivatedByPointer = activationState.isProgrammatic ? false : event !== undefined && (event.type === 'mousedown' || event.type === 'touchstart' || event.type === 'pointerdown'); const hasActivatedChild = event !== undefined && activatedTargets.length > 0 && activatedTargets.some((target) => this.adapter.containsEventTarget(target)); if (hasActivatedChild) { // Immediately reset activation state, while preserving logic that // prevents touch follow-on events this.resetActivationState(); return; } if (event !== undefined) { activatedTargets.push(event.target); this.registerDeactivationHandlers(event); } activationState.wasElementMadeActive = this.checkElementMadeActive(event); if (activationState.wasElementMadeActive) { this.animateActivation(); } requestAnimationFrame(() => { // Reset array on next frame after the current event has had a chance to // bubble to prevent ancestor ripples activatedTargets = []; if (!activationState.wasElementMadeActive && event !== undefined && (event.key === ' ' || event.keyCode === 32)) { // If space was pressed, try again within an rAF call to detect :active, // because different UAs report active states inconsistently when // they're called within event handling code: // - https://bugs.chromium.org/p/chromium/issues/detail?id=635971 // - https://bugzilla.mozilla.org/show_bug.cgi?id=1293741 // We try first outside rAF to support Edge, which does not exhibit this // problem, but will crash if a CSS variable is set within a rAF // callback for a submit button interaction (#2241). activationState.wasElementMadeActive = this.checkElementMadeActive(event); if (activationState.wasElementMadeActive) { this.animateActivation(); } } if (!activationState.wasElementMadeActive) { // Reset activation state immediately if element was not made active. this.activationState = this.defaultActivationState(); } }); } checkElementMadeActive(event) { return event !== undefined && event.type === 'keydown' ? this.adapter.isSurfaceActive() : true; } animateActivation() { const { VAR_FG_TRANSLATE_START, VAR_FG_TRANSLATE_END } = MDCRippleFoundation.strings; const { FG_DEACTIVATION, FG_ACTIVATION } = MDCRippleFoundation.cssClasses; const { DEACTIVATION_TIMEOUT_MS } = MDCRippleFoundation.numbers; this.layoutInternal(); let translateStart = ''; let translateEnd = ''; 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.updateCssVariable(VAR_FG_TRANSLATE_START, translateStart); this.adapter.updateCssVariable(VAR_FG_TRANSLATE_END, translateEnd); // Cancel any ongoing activation/deactivation animations clearTimeout(this.activationTimer); clearTimeout(this.fgDeactivationRemovalTimer); this.rmBoundedActivationClasses(); this.adapter.removeClass(FG_DEACTIVATION); // Force layout in order to re-trigger the animation. this.adapter.computeBoundingRect(); this.adapter.addClass(FG_ACTIVATION); this.activationTimer = setTimeout(() => { this.activationTimerCallback(); }, DEACTIVATION_TIMEOUT_MS); } getFgTranslationCoordinates() { const { activationEvent, wasActivatedByPointer } = this.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() { // This method is called both when a pointing device is released, and when // the activation animation ends. The deactivation animation should only run // after both of those occur. const { FG_DEACTIVATION } = MDCRippleFoundation.cssClasses; const { hasDeactivationUXRun, isActivated } = this.activationState; const activationHasEnded = hasDeactivationUXRun || !isActivated; if (activationHasEnded && this.activationAnimationHasEnded) { this.rmBoundedActivationClasses(); this.adapter.addClass(FG_DEACTIVATION); this.fgDeactivationRemovalTimer = setTimeout(() => { this.adapter.removeClass(FG_DEACTIVATION); }, numbers.FG_DEACTIVATION_MS); } } rmBoundedActivationClasses() { const { FG_ACTIVATION } = MDCRippleFoundation.cssClasses; this.adapter.removeClass(FG_ACTIVATION); this.activationAnimationHasEnded = false; this.adapter.computeBoundingRect(); } resetActivationState() { this.previousActivationEvent = this.activationState.activationEvent; this.activationState = this.defaultActivationState(); // Touch devices may fire additional events for the same interaction within // a short time. Store the previous event until it's safe to assume that // subsequent events are for new interactions. setTimeout(() => (this.previousActivationEvent = undefined), MDCRippleFoundation.numbers.TAP_DELAY_MS); } deactivateImpl() { const activationState = this.activationState; // This can happen in scenarios such as when you have a keyup event that // blurs the element. if (!activationState.isActivated) { return; } const state = Object.assign({}, activationState); if (activationState.isProgrammatic) { requestAnimationFrame(() => { this.animateDeactivation(state); }); this.resetActivationState(); } else { this.deregisterDeactivationHandlers(); requestAnimationFrame(() => { this.activationState.hasDeactivationUXRun = true; this.animateDeactivation(state); this.resetActivationState(); }); } } animateDeactivation({ wasActivatedByPointer, wasElementMadeActive, }) { if (wasActivatedByPointer || wasElementMadeActive) { this.runDeactivationUXLogicIfReady(); } } layoutInternal() { this.frame = this.adapter.computeBoundingRect(); const maxDim = Math.max(this.frame.height, this.frame.width); // Surface diameter is treated differently for unbounded vs. bounded // ripples. Unbounded ripple diameter is calculated smaller since the // surface is expected to already be padded appropriately to extend the // hitbox, and the ripple is expected to meet the edges of the padded hitbox // (which is typically square). Bounded ripples, on the other hand, are // fully expected to expand beyond the surface's longest diameter // (calculated based on the diagonal plus a constant padding), and are // clipped at the surface's border via `overflow: hidden`. const getBoundedRadius = () => { const hypotenuse = Math.sqrt(Math.pow(this.frame.width, 2) + Math.pow(this.frame.height, 2)); return hypotenuse + MDCRippleFoundation.numbers.PADDING; }; this.maxRadius = this.adapter.isUnbounded() ? maxDim : getBoundedRadius(); // Ripple is sized as a fraction of the largest dimension of the surface, // then scales up using a CSS scale transform const initialSize = Math.floor(maxDim * MDCRippleFoundation.numbers.INITIAL_ORIGIN_SCALE); // Unbounded ripple size should always be even number to equally center // align. if (this.adapter.isUnbounded() && initialSize % 2 !== 0) { this.initialSize = initialSize - 1; } else { this.initialSize = initialSize; } this.fgScale = `${this.maxRadius / this.initialSize}`; this.updateLayoutCssVars(); } updateLayoutCssVars() { const { VAR_FG_SIZE, VAR_LEFT, VAR_TOP, VAR_FG_SCALE } = MDCRippleFoundation.strings; this.adapter.updateCssVariable(VAR_FG_SIZE, `${this.initialSize}px`); this.adapter.updateCssVariable(VAR_FG_SCALE, this.fgScale); if (this.adapter.isUnbounded()) { this.unboundedCoords = { left: Math.round(this.frame.width / 2 - this.initialSize / 2), top: Math.round(this.frame.height / 2 - this.initialSize / 2), }; this.adapter.updateCssVariable(VAR_LEFT, `${this.unboundedCoords.left}px`); this.adapter.updateCssVariable(VAR_TOP, `${this.unboundedCoords.top}px`); } } } // tslint:disable-next-line:no-default-export Needed for backward compatibility with MDC Web v0.44.0 and earlier. export default MDCRippleFoundation; //# sourceMappingURL=foundation.js.map