Andrew Jakubowicz a22dd4908c I ran codemod, then addressed TypeScript errors in the most naive way.
Moving decorators from getters -> setters.
Wrapping inherited properties with the same decorators as base class.
2023-09-06 10:25:56 -07:00

455 lines
12 KiB
TypeScript

/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {html, LitElement, PropertyValues} from 'lit';
import {property, query, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {Attachable, 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`
*/
enum 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`.
*/
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`.
*/
TOUCH_DELAY,
/**
* A touch has been deemed to be a press
*
* Transitions:
* - on up: transition to `WAITING_FOR_CLICK`.
*/
HOLDING,
/**
* The user touch has finished, transition into rest state.
*
* Transitions:
* - on click end press; transition to `INACTIVE`.
*/
WAITING_FOR_CLICK
}
/**
* 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;
/**
* A ripple component.
*/
export class Ripple extends LitElement implements Attachable {
/**
* Disables the ripple.
*/
@property({type: Boolean, reflect: true}) accessor disabled = false;
get htmlFor() {
return this.attachableController.htmlFor;
}
set htmlFor(htmlFor: string|null) {
this.attachableController.htmlFor = htmlFor;
}
get control() {
return this.attachableController.control;
}
set control(control: HTMLElement|null) {
this.attachableController.control = control;
}
@state() private accessor hovered = false;
@state() private accessor pressed = false;
@query('.surface')
private accessor mdRoot!: HTMLElement|null;
private rippleSize = '';
private rippleScale = '';
private initialSize = 0;
private growAnimation?: Animation;
private state = State.INACTIVE;
private rippleStartEvent?: PointerEvent;
private checkBoundsAfterContextMenu = false;
private readonly attachableController =
new AttachableController(this, this.onControlChange.bind(this));
attach(control: HTMLElement) {
this.attachableController.attach(control);
}
detach() {
this.attachableController.detach();
}
protected override render() {
const classes = {
'hovered': this.hovered,
'pressed': this.pressed,
};
return html`<div class="surface ${classMap(classes)}"></div>`;
}
protected override update(changedProps: PropertyValues<this>) {
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: PointerEvent) {
if (!this.shouldReactToEvent(event)) {
return;
}
this.hovered = true;
}
/**
* TODO(b/269799771): make private
* @private only public for slider
*/
handlePointerleave(event: PointerEvent) {
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();
}
}
private handlePointerup(event: PointerEvent) {
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;
}
}
private async handlePointerdown(event: PointerEvent) {
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);
}
private 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();
}
}
private handlePointercancel(event: PointerEvent) {
if (!this.shouldReactToEvent(event)) {
return;
}
this.endPressAnimation();
}
private handleContextmenu() {
if (this.disabled) {
return;
}
this.checkBoundsAfterContextMenu = true;
this.endPressAnimation();
}
private 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`;
}
private getNormalizedPointerEventCoords(pointerEvent: PointerEvent):
{x: number, y: number} {
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};
}
private getTranslationCoordinates(positionEvent?: Event) {
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};
}
private startPressAnimation(positionEvent?: Event) {
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
});
}
private async endPressAnimation() {
this.state = State.INACTIVE;
const animation = this.growAnimation;
const pressAnimationPlayState = animation?.currentTime ?? Infinity;
// TODO: go/ts51upgrade - Auto-added to unblock TS5.1 migration.
// TS2365: Operator '>=' cannot be applied to types 'CSSNumberish' and
// 'number'.
// @ts-ignore
if (pressAnimationPlayState >= MINIMUM_PRESS_MS) {
this.pressed = false;
return;
}
await new Promise(resolve => {
// TODO: go/ts51upgrade - Auto-added to unblock TS5.1 migration.
// TS2363: The right-hand side of an arithmetic operation must be of
// type 'any', 'number', 'bigint' or an enum type.
// @ts-ignore
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
*/
private shouldReactToEvent(event: PointerEvent) {
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.
*/
private inBounds({x, y}: PointerEvent) {
const {top, left, bottom, right} = this.getBoundingClientRect();
return x >= left && x <= right && y >= top && y <= bottom;
}
private isTouch({pointerType}: PointerEvent) {
return pointerType === 'touch';
}
/** @private */
async handleEvent(event: Event) {
switch (event.type) {
case 'click':
this.handleClick();
break;
case 'contextmenu':
this.handleContextmenu();
break;
case 'pointercancel':
this.handlePointercancel(event as PointerEvent);
break;
case 'pointerdown':
await this.handlePointerdown(event as PointerEvent);
break;
case 'pointerenter':
this.handlePointerenter(event as PointerEvent);
break;
case 'pointerleave':
this.handlePointerleave(event as PointerEvent);
break;
case 'pointerup':
this.handlePointerup(event as PointerEvent);
break;
default:
break;
}
}
private onControlChange(prev: HTMLElement|null, next: HTMLElement|null) {
for (const event of EVENTS) {
prev?.removeEventListener(event, this);
next?.addEventListener(event, this);
}
}
}