diff --git a/chips/action/lib/action.ts b/chips/action/lib/action.ts index 69912aa16f..c72aecbd8b 100644 --- a/chips/action/lib/action.ts +++ b/chips/action/lib/action.ts @@ -84,12 +84,12 @@ export abstract class Action extends ActionElement { } override beginPress({positionEvent}: BeginPressConfig) { - this.ripple?.beginPress(positionEvent); + // TODO(b/253297063): connect to ripple } override endPress(options: EndPressConfig) { super.endPress(options); - this.ripple?.endPress(); + // TODO(b/253297063): connect to ripple if (!options.cancelled) { this.dispatchCustomEvent(this.getInteractionEvent()); } diff --git a/chips/action/lib/link-action.ts b/chips/action/lib/link-action.ts index 98c8904279..760e5988a4 100644 --- a/chips/action/lib/link-action.ts +++ b/chips/action/lib/link-action.ts @@ -52,6 +52,6 @@ export class LinkAction extends PrimaryAction { override endPress(options: EndPressConfig) { super.endPress(options); - this.ripple?.endPress(); + // TODO(b/253297063): connect to ripple } } diff --git a/navigationtab/lib/navigation-tab.ts b/navigationtab/lib/navigation-tab.ts index 5885e70f74..bcd93e6af7 100644 --- a/navigationtab/lib/navigation-tab.ts +++ b/navigationtab/lib/navigation-tab.ts @@ -129,11 +129,11 @@ export class NavigationTab extends ActionElement implements NavigationTabState { } override beginPress({positionEvent}: BeginPressConfig) { - this.ripple.beginPress(positionEvent); + // TODO(b/269772145): connect to ripple } override endPress(options: EndPressConfig) { - this.ripple.endPress(); + // TODO(b/269772145): connect to ripple super.endPress(options); if (!options.cancelled) { this.dispatchEvent(new CustomEvent( diff --git a/ripple/directive.ts b/ripple/directive.ts index d5de034b7d..a170c14958 100644 --- a/ripple/directive.ts +++ b/ripple/directive.ts @@ -7,13 +7,7 @@ import {noChange} from 'lit'; import {Directive, directive, DirectiveParameters, ElementPart, PartInfo, PartType} from 'lit/directive.js'; -import {Ripple, State} from './lib/ripple.js'; - -/** - * Delay reacting to touch so that we do not show the ripple for a swipe or - * scroll interaction. - */ -const TOUCH_DELAY_MS = 150; +import {Ripple} from './lib/ripple.js'; /** * Normalized ripple accessor type. @@ -25,9 +19,6 @@ type RippleFunction = () => Ripple|null|Promise; class RippleDirective extends Directive { private rippleGetter: RippleFunction = async () => null; private element?: HTMLElement; - private checkBoundsAfterContextMenu = false; - private rippleStartEvent: PointerEvent|null = null; - private touchTimer: number|null = null; constructor(partInfo: PartInfo) { super(partInfo); @@ -49,25 +40,25 @@ class RippleDirective extends Directive { } switch (event.type) { case 'click': - this.click(ripple); + ripple.handleClick(); break; case 'contextmenu': - this.contextMenu(ripple); + ripple.handleContextmenu(); break; case 'pointercancel': - this.pointerCancel(ripple, event as PointerEvent); + ripple.handlePointercancel(event as PointerEvent); break; case 'pointerdown': - this.pointerDown(ripple, event as PointerEvent); + await ripple.handlePointerdown(event as PointerEvent); break; case 'pointerenter': ripple.handlePointerenter(event as PointerEvent); break; case 'pointerleave': - this.pointerLeave(ripple, event as PointerEvent); + ripple.handlePointerleave(event as PointerEvent); break; case 'pointerup': - this.pointerUp(ripple, event as PointerEvent); + ripple.handlePointerup(event as PointerEvent); break; default: break; @@ -91,138 +82,6 @@ class RippleDirective extends Directive { this.rippleGetter = typeof ripple === 'function' ? ripple : () => ripple; return noChange; } - - /** - * 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( - ripple: Ripple, ev: PointerEvent, hovering = false) { - const enabled = !ripple.disabled; - const isPrimaryPointer = ev.isPrimary; - const isInteractionPointer = this.rippleStartEvent === null || - this.rippleStartEvent.pointerId === ev.pointerId; - const isPrimaryButton = ev.buttons === 1; - return enabled && isPrimaryPointer && isInteractionPointer && - (this.isTouch(ev) || isPrimaryButton || hovering); - } - - private isTouch({pointerType}: PointerEvent) { - return pointerType === 'touch'; - } - - /** - * 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.element!.getBoundingClientRect(); - return x >= left && x <= right && y >= top && y <= bottom; - } - - private beginPress(ripple: Ripple) { - ripple.beginPress(this.rippleStartEvent); - } - - private endPress(ripple: Ripple) { - ripple.endPress(); - ripple.state = State.INACTIVE; - this.rippleStartEvent = null; - if (this.touchTimer) { - clearTimeout(this.touchTimer); - this.touchTimer = null; - } - } - - private waitForTouchHold(ripple: Ripple) { - if (this.touchTimer !== null) { - clearTimeout(this.touchTimer); - } - ripple.state = State.TOUCH_DELAY; - this.touchTimer = setTimeout(() => { - if (ripple.state !== State.TOUCH_DELAY) { - return; - } - ripple.state = State.HOLDING; - this.beginPress(ripple); - }, TOUCH_DELAY_MS); - } - - private click(ripple: Ripple) { - // Click is a MouseEvent in Firefox and Safari, so we cannot use - // `shouldReactToEvent` - if (ripple.disabled) { - return; - } - if (ripple.state === State.WAITING_FOR_CLICK) { - this.endPress(ripple); - } else if (ripple.state === State.INACTIVE) { - // keyboard synthesized click event - this.beginPress(ripple); - this.endPress(ripple); - } - } - - private contextMenu(ripple: Ripple) { - if (!ripple.disabled) { - this.checkBoundsAfterContextMenu = true; - this.endPress(ripple); - } - } - - private pointerDown(ripple: Ripple, ev: PointerEvent) { - if (!this.shouldReactToEvent(ripple, ev)) { - return; - } - this.rippleStartEvent = ev; - if (this.isTouch(ev)) { - // 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(ev)) { - return; - } - this.checkBoundsAfterContextMenu = false; - this.waitForTouchHold(ripple); - } else { - ripple.state = State.WAITING_FOR_CLICK; - this.beginPress(ripple); - } - } - - private pointerUp(ripple: Ripple, ev: PointerEvent) { - if (!this.isTouch(ev) || !this.shouldReactToEvent(ripple, ev)) { - return; - } - if (ripple.state === State.HOLDING) { - ripple.state = State.WAITING_FOR_CLICK; - } else if (ripple.state === State.TOUCH_DELAY) { - ripple.state = State.WAITING_FOR_CLICK; - this.beginPress(ripple); - } - } - - private pointerCancel(ripple: Ripple, ev: PointerEvent) { - if (this.shouldReactToEvent(ripple, ev)) { - this.endPress(ripple); - } - } - - private pointerLeave(ripple: Ripple, ev: PointerEvent) { - if (this.shouldReactToEvent(ripple, ev, true)) { - ripple.handlePointerleave(ev); - // release a held mouse or pen press that moves outside the element - if (!this.isTouch(ev) && ripple.state !== State.INACTIVE) { - this.endPress(ripple); - } - } - } } /** diff --git a/ripple/lib/ripple.ts b/ripple/lib/ripple.ts index d950875733..b835bc753f 100644 --- a/ripple/lib/ripple.ts +++ b/ripple/lib/ripple.ts @@ -19,7 +19,6 @@ const SOFT_EDGE_CONTAINER_RATIO = 0.35; const PRESS_PSEUDO = '::after'; const ANIMATION_FILL = 'forwards'; -// TODO(b/269633197): do not export State /** * Interaction states for the ripple. * @@ -30,7 +29,7 @@ const ANIMATION_FILL = 'forwards'; * On Mouse or Pen: * - `INACTIVE -> WAITING_FOR_CLICK -> INACTIVE` */ -export enum State { +enum State { /** * Initial state of the control, no touch in progress. * @@ -65,6 +64,12 @@ export enum State { WAITING_FOR_CLICK } +/** + * 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. */ @@ -91,12 +96,12 @@ export class Ripple extends LitElement { private rippleScale = ''; private initialSize = 0; private growAnimation?: Animation; - private delayedEndPressHandle?: number; - // TODO(b/269633197): make ripple state private - state = State.INACTIVE; + private state = State.INACTIVE; + private rippleStartEvent?: PointerEvent; + private checkBoundsAfterContextMenu = false; handlePointerenter(event: PointerEvent) { - if (this.isTouch(event) || !this.shouldReactToEvent(event)) { + if (!this.shouldReactToEvent(event)) { return; } @@ -109,6 +114,11 @@ export class Ripple extends LitElement { } this.hovered = false; + + // release a held mouse or pen press that moves outside the element + if (this.state !== State.INACTIVE) { + this.endPressAnimation(); + } } handleFocusin() { @@ -119,21 +129,92 @@ export class Ripple extends LitElement { this.focused = false; } - beginPress(positionEvent?: Event|null) { - this.pressed = true; - clearTimeout(this.delayedEndPressHandle); - this.startPressAnimation(positionEvent); + 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; + } } - endPress() { - const pressAnimationPlayState = this.growAnimation?.currentTime ?? Infinity; - if (pressAnimationPlayState >= MINIMUM_PRESS_MS) { - this.pressed = false; - } else { - this.delayedEndPressHandle = setTimeout(() => { - this.pressed = false; - }, MINIMUM_PRESS_MS - pressAnimationPlayState); + 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); + } + + 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: PointerEvent) { + if (!this.shouldReactToEvent(event)) { + return; + } + + this.endPressAnimation(); + } + + handleContextmenu() { + if (this.disabled) { + return; + } + + this.checkBoundsAfterContextMenu = true; + this.endPressAnimation(); } protected override render() { @@ -151,7 +232,7 @@ export class Ripple extends LitElement { if (changedProps.has('disabled') && this.disabled) { this.hovered = false; this.focused = false; - this.endPress(); + this.pressed = false; } super.update(changedProps); } @@ -193,7 +274,7 @@ export class Ripple extends LitElement { return {x: pageX - documentX, y: pageY - documentY}; } - private getTranslationCoordinates(positionEvent?: Event|null) { + private getTranslationCoordinates(positionEvent?: Event) { const {height, width} = this.getDimensions(); // end in the center const endPoint = { @@ -220,7 +301,8 @@ export class Ripple extends LitElement { return {startPoint, endPoint}; } - private startPressAnimation(positionEvent?: Event|null) { + private startPressAnimation(positionEvent?: Event) { + this.pressed = true; this.growAnimation?.cancel(); this.determineRippleSize(); const {startPoint, endPoint} = @@ -247,13 +329,62 @@ export class Ripple extends LitElement { }); } + private async endPressAnimation() { + const animation = this.growAnimation; + const pressAnimationPlayState = animation?.currentTime ?? Infinity; + 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 */ private shouldReactToEvent(event: PointerEvent) { - return !this.disabled && event.isPrimary; + 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) { diff --git a/segmentedbutton/lib/segmented-button.ts b/segmentedbutton/lib/segmented-button.ts index 73e2e423fa..9fd77e493d 100644 --- a/segmentedbutton/lib/segmented-button.ts +++ b/segmentedbutton/lib/segmented-button.ts @@ -67,11 +67,11 @@ export class SegmentedButton extends ActionElement { } override beginPress({positionEvent}: BeginPressConfig) { - this.ripple.beginPress(positionEvent); + // TODO(b/261201805): connect to ripple } override endPress(options: EndPressConfig) { - this.ripple.endPress(); + // TODO(b/261201805): connect to ripple super.endPress(options); if (!options.cancelled) { const event = new Event(