UNPKG

@material/web

Version:
423 lines 13.7 kB
/** * @license * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { __decorate } from "tslib"; import { html, isServer, LitElement } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { AttachableController, } from '../../internal/controller/attachable-controller.js'; import { EASING } from '../../internal/motion/animation.js'; const PRESS_GROW_MS = 450; const MINIMUM_PRESS_MS = 225; const INITIAL_ORIGIN_SCALE = 0.2; const PADDING = 10; const SOFT_EDGE_MINIMUM_SIZE = 75; const SOFT_EDGE_CONTAINER_RATIO = 0.35; const PRESS_PSEUDO = '::after'; const ANIMATION_FILL = 'forwards'; /** * Interaction states for the ripple. * * On Touch: * - `INACTIVE -> TOUCH_DELAY -> WAITING_FOR_CLICK -> INACTIVE` * - `INACTIVE -> TOUCH_DELAY -> HOLDING -> WAITING_FOR_CLICK -> INACTIVE` * * On Mouse or Pen: * - `INACTIVE -> WAITING_FOR_CLICK -> INACTIVE` */ var State; (function (State) { /** * Initial state of the control, no touch in progress. * * Transitions: * - on touch down: transition to `TOUCH_DELAY`. * - on mouse down: transition to `WAITING_FOR_CLICK`. */ State[State["INACTIVE"] = 0] = "INACTIVE"; /** * Touch down has been received, waiting to determine if it's a swipe or * scroll. * * Transitions: * - on touch up: begin press; transition to `WAITING_FOR_CLICK`. * - on cancel: transition to `INACTIVE`. * - after `TOUCH_DELAY_MS`: begin press; transition to `HOLDING`. */ State[State["TOUCH_DELAY"] = 1] = "TOUCH_DELAY"; /** * A touch has been deemed to be a press * * Transitions: * - on up: transition to `WAITING_FOR_CLICK`. */ State[State["HOLDING"] = 2] = "HOLDING"; /** * The user touch has finished, transition into rest state. * * Transitions: * - on click end press; transition to `INACTIVE`. */ State[State["WAITING_FOR_CLICK"] = 3] = "WAITING_FOR_CLICK"; })(State || (State = {})); /** * Events that the ripple listens to. */ const EVENTS = [ 'click', 'contextmenu', 'pointercancel', 'pointerdown', 'pointerenter', 'pointerleave', 'pointerup', ]; /** * Delay reacting to touch so that we do not show the ripple for a swipe or * scroll interaction. */ const TOUCH_DELAY_MS = 150; /** * Used to detect if HCM is active. Events do not process during HCM when the * ripple is not displayed. */ const FORCED_COLORS = isServer ? null : window.matchMedia('(forced-colors: active)'); /** * A ripple component. */ export class Ripple extends LitElement { constructor() { super(...arguments); /** * Disables the ripple. */ this.disabled = false; this.hovered = false; this.pressed = false; this.rippleSize = ''; this.rippleScale = ''; this.initialSize = 0; this.state = State.INACTIVE; this.checkBoundsAfterContextMenu = false; this.attachableController = new AttachableController(this, this.onControlChange.bind(this)); } get htmlFor() { return this.attachableController.htmlFor; } set htmlFor(htmlFor) { this.attachableController.htmlFor = htmlFor; } get control() { return this.attachableController.control; } set control(control) { this.attachableController.control = control; } attach(control) { this.attachableController.attach(control); } detach() { this.attachableController.detach(); } connectedCallback() { super.connectedCallback(); // Needed for VoiceOver, which will create a "group" if the element is a // sibling to other content. this.setAttribute('aria-hidden', 'true'); } render() { const classes = { 'hovered': this.hovered, 'pressed': this.pressed, }; return html `<div class="surface ${classMap(classes)}"></div>`; } update(changedProps) { if (changedProps.has('disabled') && this.disabled) { this.hovered = false; this.pressed = false; } super.update(changedProps); } /** * TODO(b/269799771): make private * @private only public for slider */ handlePointerenter(event) { if (!this.shouldReactToEvent(event)) { return; } this.hovered = true; } /** * TODO(b/269799771): make private * @private only public for slider */ handlePointerleave(event) { if (!this.shouldReactToEvent(event)) { return; } this.hovered = false; // release a held mouse or pen press that moves outside the element if (this.state !== State.INACTIVE) { this.endPressAnimation(); } } handlePointerup(event) { if (!this.shouldReactToEvent(event)) { return; } if (this.state === State.HOLDING) { this.state = State.WAITING_FOR_CLICK; return; } if (this.state === State.TOUCH_DELAY) { this.state = State.WAITING_FOR_CLICK; this.startPressAnimation(this.rippleStartEvent); return; } } async handlePointerdown(event) { if (!this.shouldReactToEvent(event)) { return; } this.rippleStartEvent = event; if (!this.isTouch(event)) { this.state = State.WAITING_FOR_CLICK; this.startPressAnimation(event); return; } // after a longpress contextmenu event, an extra `pointerdown` can be // dispatched to the pressed element. Check that the down is within // bounds of the element in this case. if (this.checkBoundsAfterContextMenu && !this.inBounds(event)) { return; } this.checkBoundsAfterContextMenu = false; // Wait for a hold after touch delay this.state = State.TOUCH_DELAY; await new Promise((resolve) => { setTimeout(resolve, TOUCH_DELAY_MS); }); if (this.state !== State.TOUCH_DELAY) { return; } this.state = State.HOLDING; this.startPressAnimation(event); } handleClick() { // Click is a MouseEvent in Firefox and Safari, so we cannot use // `shouldReactToEvent` if (this.disabled) { return; } if (this.state === State.WAITING_FOR_CLICK) { this.endPressAnimation(); return; } if (this.state === State.INACTIVE) { // keyboard synthesized click event this.startPressAnimation(); this.endPressAnimation(); } } handlePointercancel(event) { if (!this.shouldReactToEvent(event)) { return; } this.endPressAnimation(); } handleContextmenu() { if (this.disabled) { return; } this.checkBoundsAfterContextMenu = true; this.endPressAnimation(); } determineRippleSize() { const { height, width } = this.getBoundingClientRect(); const maxDim = Math.max(height, width); const softEdgeSize = Math.max(SOFT_EDGE_CONTAINER_RATIO * maxDim, SOFT_EDGE_MINIMUM_SIZE); const initialSize = Math.floor(maxDim * INITIAL_ORIGIN_SCALE); const hypotenuse = Math.sqrt(width ** 2 + height ** 2); const maxRadius = hypotenuse + PADDING; this.initialSize = initialSize; this.rippleScale = `${(maxRadius + softEdgeSize) / initialSize}`; this.rippleSize = `${initialSize}px`; } getNormalizedPointerEventCoords(pointerEvent) { const { scrollX, scrollY } = window; const { left, top } = this.getBoundingClientRect(); const documentX = scrollX + left; const documentY = scrollY + top; const { pageX, pageY } = pointerEvent; return { x: pageX - documentX, y: pageY - documentY }; } getTranslationCoordinates(positionEvent) { const { height, width } = this.getBoundingClientRect(); // end in the center const endPoint = { x: (width - this.initialSize) / 2, y: (height - this.initialSize) / 2, }; let startPoint; if (positionEvent instanceof PointerEvent) { startPoint = this.getNormalizedPointerEventCoords(positionEvent); } else { startPoint = { x: width / 2, y: height / 2, }; } // center around start point startPoint = { x: startPoint.x - this.initialSize / 2, y: startPoint.y - this.initialSize / 2, }; return { startPoint, endPoint }; } startPressAnimation(positionEvent) { if (!this.mdRoot) { return; } this.pressed = true; this.growAnimation?.cancel(); this.determineRippleSize(); const { startPoint, endPoint } = this.getTranslationCoordinates(positionEvent); const translateStart = `${startPoint.x}px, ${startPoint.y}px`; const translateEnd = `${endPoint.x}px, ${endPoint.y}px`; this.growAnimation = this.mdRoot.animate({ top: [0, 0], left: [0, 0], height: [this.rippleSize, this.rippleSize], width: [this.rippleSize, this.rippleSize], transform: [ `translate(${translateStart}) scale(1)`, `translate(${translateEnd}) scale(${this.rippleScale})`, ], }, { pseudoElement: PRESS_PSEUDO, duration: PRESS_GROW_MS, easing: EASING.STANDARD, fill: ANIMATION_FILL, }); } async endPressAnimation() { this.rippleStartEvent = undefined; this.state = State.INACTIVE; const animation = this.growAnimation; let pressAnimationPlayState = Infinity; if (typeof animation?.currentTime === 'number') { pressAnimationPlayState = animation.currentTime; } else if (animation?.currentTime) { pressAnimationPlayState = animation.currentTime.to('ms').value; } if (pressAnimationPlayState >= MINIMUM_PRESS_MS) { this.pressed = false; return; } await new Promise((resolve) => { setTimeout(resolve, MINIMUM_PRESS_MS - pressAnimationPlayState); }); if (this.growAnimation !== animation) { // A new press animation was started. The old animation was canceled and // should not finish the pressed state. return; } this.pressed = false; } /** * Returns `true` if * - the ripple element is enabled * - the pointer is primary for the input type * - the pointer is the pointer that started the interaction, or will start * the interaction * - the pointer is a touch, or the pointer state has the primary button * held, or the pointer is hovering */ shouldReactToEvent(event) { if (this.disabled || !event.isPrimary) { return false; } if (this.rippleStartEvent && this.rippleStartEvent.pointerId !== event.pointerId) { return false; } if (event.type === 'pointerenter' || event.type === 'pointerleave') { return !this.isTouch(event); } const isPrimaryButton = event.buttons === 1; return this.isTouch(event) || isPrimaryButton; } /** * Check if the event is within the bounds of the element. * * This is only needed for the "stuck" contextmenu longpress on Chrome. */ inBounds({ x, y }) { const { top, left, bottom, right } = this.getBoundingClientRect(); return x >= left && x <= right && y >= top && y <= bottom; } isTouch({ pointerType }) { return pointerType === 'touch'; } /** @private */ async handleEvent(event) { if (FORCED_COLORS?.matches) { // Skip event logic since the ripple is `display: none`. return; } switch (event.type) { case 'click': this.handleClick(); break; case 'contextmenu': this.handleContextmenu(); break; case 'pointercancel': this.handlePointercancel(event); break; case 'pointerdown': await this.handlePointerdown(event); break; case 'pointerenter': this.handlePointerenter(event); break; case 'pointerleave': this.handlePointerleave(event); break; case 'pointerup': this.handlePointerup(event); break; default: break; } } onControlChange(prev, next) { if (isServer) return; for (const event of EVENTS) { prev?.removeEventListener(event, this); next?.addEventListener(event, this); } } } __decorate([ property({ type: Boolean, reflect: true }) ], Ripple.prototype, "disabled", void 0); __decorate([ state() ], Ripple.prototype, "hovered", void 0); __decorate([ state() ], Ripple.prototype, "pressed", void 0); __decorate([ query('.surface') ], Ripple.prototype, "mdRoot", void 0); //# sourceMappingURL=ripple.js.map