@material/web
Version:
Material web components
423 lines • 13.7 kB
JavaScript
/**
* @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