mirror of
https://github.com/material-components/material-web.git
synced 2026-03-09 00:09:23 +08:00
372 lines
10 KiB
TypeScript
372 lines
10 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2022 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {ReactiveController, ReactiveControllerHost} from 'lit';
|
|
|
|
/**
|
|
* Enumeration to keep track of the lifecycle of a touch event.
|
|
*/
|
|
|
|
// State transition diagram:
|
|
// +-----------------------------+
|
|
// | v
|
|
// | +------+------ WAITING_FOR_MOUSE_CLICK<----+
|
|
// | | | ^ |
|
|
// | V | | |
|
|
// => INACTIVE -> TOUCH_DELAY -> RELEASING HOLDING
|
|
// | ^
|
|
// | |
|
|
// +-----------------------------------+
|
|
enum Phase {
|
|
// Initial state of the control, no touch in progress.
|
|
// Transitions:
|
|
// on touch down: transition to TOUCH_DELAY.
|
|
// on mouse down: transition to WAITING_FOR_MOUSE_CLICK.
|
|
INACTIVE = 'INACTIVE',
|
|
|
|
// Touch down has been received, waiting to determine if it's a swipe.
|
|
// Transitions:
|
|
// on touch up: beginPress(); transition to RELEASING.
|
|
// on cancel: transition to INACTIVE.
|
|
// after TOUCH_DELAY_MS: beginPress(); transition to HOLDING.
|
|
TOUCH_DELAY = 'TOUCH_DELAY',
|
|
|
|
// A touch has been deemed to be a press
|
|
// Transitions:
|
|
// on pointerup: endPress(); transition to WAITING_FOR_MOUSE_CLICK.
|
|
HOLDING = 'HOLDING',
|
|
|
|
// The user has released the mouse / touch, but we want to delay calling
|
|
// endPress for a little bit to avoid double clicks.
|
|
// Transitions:
|
|
// mouse sequence after debounceDelay: endPress(); transition to INACTIVE
|
|
// when in touch sequence: transitions directly to WAITING_FOR_MOUSE_CLICK
|
|
RELEASING = 'RELEASING',
|
|
|
|
// The user has touched, but we want to delay endPress until synthetic mouse
|
|
// click event occurs. Stay in this state for a fixed amount of time before
|
|
// giving up and transitioning into rest state.
|
|
// Transitions:
|
|
// on click: endPress(); transition to INACTIVE.
|
|
// after WAIT_FOR_MOUSE_CLICK_MS: transition to INACTIVE.
|
|
WAITING_FOR_MOUSE_CLICK = 'WAITING_FOR_MOUSE_CLICK'
|
|
}
|
|
|
|
/**
|
|
* Delay time from touchstart to when element#beginPress is invoked.
|
|
*/
|
|
export const TOUCH_DELAY_MS = 150;
|
|
|
|
/**
|
|
* Delay time from beginning to wait for synthetic mouse events till giving up.
|
|
*/
|
|
export const WAIT_FOR_MOUSE_CLICK_MS = 500;
|
|
|
|
/**
|
|
* Interface for argument to beginPress.
|
|
*/
|
|
export interface BeginPressConfig {
|
|
/**
|
|
* Event that was recorded at the start of the interaction.
|
|
* `null` if the press happened via keyboard.
|
|
*/
|
|
positionEvent: Event|null;
|
|
}
|
|
|
|
/**
|
|
* Interface for argument to endPress.
|
|
*/
|
|
export interface EndPressConfig {
|
|
/**
|
|
* `true` if the press was cancelled.
|
|
*/
|
|
cancelled: boolean;
|
|
/**
|
|
* Data object to pass along to clients in the `action` event, if relevant.
|
|
*/
|
|
actionData?: {};
|
|
}
|
|
|
|
/**
|
|
* The necessary interface for using an ActionController
|
|
*/
|
|
export interface ActionControllerHost extends ReactiveControllerHost,
|
|
HTMLElement {
|
|
disabled: boolean;
|
|
/**
|
|
* Determines if pointerdown or click events containing modifier keys should
|
|
* be ignored.
|
|
*/
|
|
ignoreClicksWithModifiers?: boolean;
|
|
/**
|
|
* Called when a user interaction is determined to be a press.
|
|
*/
|
|
beginPress(config: BeginPressConfig): void;
|
|
/**
|
|
* Called when a press ends or is cancelled.
|
|
*/
|
|
endPress(config: EndPressConfig): void;
|
|
}
|
|
|
|
/**
|
|
* ActionController normalizes user interaction on components and distills it
|
|
* into calling `beginPress` and `endPress` on the component.
|
|
*
|
|
* `beginPress` is a good hook to affect visuals for pressed state, including
|
|
* ripple.
|
|
*
|
|
* `endPress` is a good hook for firing events based on user interaction, and
|
|
* cleaning up the pressed visual state.
|
|
*
|
|
* A component using an ActionController need only implement the ActionElement
|
|
* interface and add the ActionController's event listeners to understand user
|
|
* interaction.
|
|
*/
|
|
export class ActionController implements ReactiveController {
|
|
constructor(private readonly element: ActionControllerHost) {
|
|
this.element.addController(this);
|
|
}
|
|
|
|
private get disabled() {
|
|
return this.element.disabled;
|
|
}
|
|
|
|
private get ignoreClicksWithModifiers() {
|
|
return this.element.ignoreClicksWithModifiers ?? false;
|
|
}
|
|
|
|
private phase = Phase.INACTIVE;
|
|
|
|
private touchTimer: number|null = null;
|
|
|
|
private clickTimer: number|null = null;
|
|
|
|
private lastPositionEvent: PointerEvent|null = null;
|
|
|
|
private pressed = false;
|
|
|
|
private checkBoundsAfterContextMenu = false;
|
|
|
|
private setPhase(newPhase: Phase) {
|
|
this.phase = newPhase;
|
|
}
|
|
|
|
/**
|
|
* Calls beginPress and then endPress. Allows us to programmatically click
|
|
* on the element.
|
|
*/
|
|
private press() {
|
|
this.beginPress(/* positionEvent= */ null);
|
|
this.setPhase(Phase.INACTIVE);
|
|
this.endPress();
|
|
}
|
|
|
|
/**
|
|
* Call `beginPress` on element with triggering event, if applicable.
|
|
*/
|
|
private beginPress(positionEvent: Event|null = this.lastPositionEvent) {
|
|
this.pressed = true;
|
|
this.element.beginPress({positionEvent});
|
|
}
|
|
|
|
/**
|
|
* Call `endPress` on element, and clean up timers.
|
|
*/
|
|
private endPress() {
|
|
this.pressed = false;
|
|
this.element.endPress({cancelled: false});
|
|
this.cleanup();
|
|
}
|
|
|
|
private cleanup() {
|
|
if (this.touchTimer) {
|
|
clearTimeout(this.touchTimer);
|
|
}
|
|
this.touchTimer = null;
|
|
if (this.clickTimer) {
|
|
clearTimeout(this.clickTimer);
|
|
}
|
|
this.clickTimer = null;
|
|
this.lastPositionEvent = null;
|
|
}
|
|
|
|
/**
|
|
* Call `endPress` with cancelled state on element, and cleanup timers.
|
|
*/
|
|
private cancelPress() {
|
|
this.pressed = false;
|
|
this.cleanup();
|
|
if (this.phase === Phase.TOUCH_DELAY) {
|
|
this.setPhase(Phase.INACTIVE);
|
|
} else if (this.phase !== Phase.INACTIVE) {
|
|
this.setPhase(Phase.INACTIVE);
|
|
this.element.endPress({cancelled: true});
|
|
}
|
|
}
|
|
|
|
private isTouch(e: PointerEvent) {
|
|
return e.pointerType === 'touch';
|
|
}
|
|
|
|
private touchDelayFinished() {
|
|
if (this.phase !== Phase.TOUCH_DELAY) {
|
|
return;
|
|
}
|
|
this.setPhase(Phase.HOLDING);
|
|
this.beginPress();
|
|
}
|
|
|
|
private waitForClick() {
|
|
this.setPhase(Phase.WAITING_FOR_MOUSE_CLICK);
|
|
this.clickTimer = setTimeout(() => {
|
|
// If a click event does not occur, clean up the interaction state.
|
|
if (this.phase === Phase.WAITING_FOR_MOUSE_CLICK) {
|
|
this.cancelPress();
|
|
}
|
|
}, WAIT_FOR_MOUSE_CLICK_MS);
|
|
}
|
|
|
|
/**
|
|
* Check if event should trigger actions on the element.
|
|
*/
|
|
private shouldRespondToEvent(e: PointerEvent) {
|
|
return !this.disabled && e.isPrimary;
|
|
}
|
|
|
|
/**
|
|
* Check if the event is within the bounds of the element.
|
|
*
|
|
* This is only needed for the "stuck" contextmenu longpress on Chrome.
|
|
*/
|
|
private inBounds(ev: PointerEvent) {
|
|
const {top, left, bottom, right} = this.element.getBoundingClientRect();
|
|
const {x, y} = ev;
|
|
return x >= left && x <= right && y >= top && y <= bottom;
|
|
}
|
|
|
|
private eventHasModifiers(e: MouseEvent) {
|
|
return e.altKey || e.ctrlKey || e.shiftKey || e.metaKey;
|
|
}
|
|
|
|
/**
|
|
* Cancel interactions if the element is removed from the DOM.
|
|
*/
|
|
hostDisconnected() {
|
|
this.cancelPress();
|
|
}
|
|
|
|
/**
|
|
* If the element becomes disabled, cancel interactions.
|
|
*/
|
|
hostUpdated() {
|
|
if (this.disabled) {
|
|
this.cancelPress();
|
|
}
|
|
}
|
|
|
|
// event listeners
|
|
/**
|
|
* Pointer down event handler.
|
|
*/
|
|
pointerDown =
|
|
(e: PointerEvent) => {
|
|
if (!this.shouldRespondToEvent(e) || this.phase !== Phase.INACTIVE) {
|
|
return;
|
|
}
|
|
if (this.isTouch(e)) {
|
|
// 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(e)) {
|
|
return;
|
|
}
|
|
this.checkBoundsAfterContextMenu = false;
|
|
this.lastPositionEvent = e;
|
|
this.setPhase(Phase.TOUCH_DELAY);
|
|
this.touchTimer = setTimeout(() => {
|
|
this.touchDelayFinished();
|
|
}, TOUCH_DELAY_MS);
|
|
} else {
|
|
const leftButtonPressed = e.button === 0;
|
|
if (!leftButtonPressed ||
|
|
(this.ignoreClicksWithModifiers && this.eventHasModifiers(e))) {
|
|
return;
|
|
}
|
|
this.setPhase(Phase.WAITING_FOR_MOUSE_CLICK);
|
|
this.beginPress(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pointer up event handler.
|
|
*/
|
|
pointerUp =
|
|
(e: PointerEvent) => {
|
|
if (!this.isTouch(e) || !this.shouldRespondToEvent(e)) {
|
|
return;
|
|
}
|
|
if (this.phase === Phase.HOLDING) {
|
|
this.waitForClick();
|
|
} else if (this.phase === Phase.TOUCH_DELAY) {
|
|
this.setPhase(Phase.RELEASING);
|
|
this.beginPress();
|
|
this.waitForClick();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Click event handler.
|
|
*/
|
|
click =
|
|
(e: MouseEvent) => {
|
|
if (this.disabled ||
|
|
(this.ignoreClicksWithModifiers && this.eventHasModifiers(e))) {
|
|
return;
|
|
}
|
|
if (this.phase === Phase.WAITING_FOR_MOUSE_CLICK) {
|
|
this.endPress();
|
|
this.setPhase(Phase.INACTIVE);
|
|
return;
|
|
}
|
|
|
|
// keyboard synthesized click event
|
|
if (this.phase === Phase.INACTIVE && !this.pressed) {
|
|
this.press();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pointer leave event handler.
|
|
*/
|
|
pointerLeave =
|
|
(e: PointerEvent) => {
|
|
// cancel a held press that moves outside the element
|
|
if (this.shouldRespondToEvent(e) && !this.isTouch(e) && this.pressed) {
|
|
this.cancelPress();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pointer cancel event handler.
|
|
*/
|
|
pointerCancel =
|
|
(e: PointerEvent) => {
|
|
if (this.shouldRespondToEvent(e)) {
|
|
this.cancelPress();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Contextmenu event handler.
|
|
*/
|
|
contextMenu = () => {
|
|
if (!this.disabled) {
|
|
this.checkBoundsAfterContextMenu = true;
|
|
this.cancelPress();
|
|
}
|
|
}
|
|
}
|