diff --git a/src/cdk-experimental/popover-edit/table-directives.ts b/src/cdk-experimental/popover-edit/table-directives.ts index e9daa273284d..404f5273889c 100644 --- a/src/cdk-experimental/popover-edit/table-directives.ts +++ b/src/cdk-experimental/popover-edit/table-directives.ts @@ -19,10 +19,8 @@ import { TemplateRef, ViewContainerRef, inject, - Renderer2, - ListenerOptions, } from '@angular/core'; -import {merge, Observable, Subject} from 'rxjs'; +import {fromEvent, fromEventPattern, merge, Subject} from 'rxjs'; import { debounceTime, filter, @@ -46,7 +44,6 @@ import { } from './focus-escape-notifier'; import {closest} from './polyfill'; import {EditRef} from './edit-ref'; -import {_bindEventWithOptions} from '@angular/cdk/platform'; /** * Describes the number of columns before and after the originating cell that the @@ -76,7 +73,6 @@ export class CdkEditable implements AfterViewInit, OnDestroy { inject>>(EditEventDispatcher); protected readonly focusDispatcher = inject(FocusDispatcher); protected readonly ngZone = inject(NgZone); - private readonly _renderer = inject(Renderer2); protected readonly destroyed = new Subject(); @@ -98,23 +94,6 @@ export class CdkEditable implements AfterViewInit, OnDestroy { this._rendered.complete(); } - private _observableFromEvent( - element: Element, - name: string, - options?: ListenerOptions, - ) { - return new Observable(subscriber => { - const handler = (event: T) => subscriber.next(event); - const cleanup = options - ? _bindEventWithOptions(this._renderer, element, name, handler, options) - : this._renderer.listen(element, name, handler, options); - return () => { - cleanup(); - subscriber.complete(); - }; - }); - } - private _listenForTableEvents(): void { const element = this.elementRef.nativeElement; const toClosest = (selector: string) => @@ -122,13 +101,13 @@ export class CdkEditable implements AfterViewInit, OnDestroy { this.ngZone.runOutsideAngular(() => { // Track mouse movement over the table to hide/show hover content. - this._observableFromEvent(element, 'mouseover') + fromEvent(element, 'mouseover') .pipe(toClosest(ROW_SELECTOR), takeUntil(this.destroyed)) .subscribe(this.editEventDispatcher.hovering); - this._observableFromEvent(element, 'mouseleave') + fromEvent(element, 'mouseleave') .pipe(mapTo(null), takeUntil(this.destroyed)) .subscribe(this.editEventDispatcher.hovering); - this._observableFromEvent(element, 'mousemove') + fromEvent(element, 'mousemove') .pipe( throttleTime(MOUSE_MOVE_THROTTLE_TIME_MS), toClosest(ROW_SELECTOR), @@ -137,15 +116,19 @@ export class CdkEditable implements AfterViewInit, OnDestroy { .subscribe(this.editEventDispatcher.mouseMove); // Track focus within the table to hide/show/make focusable hover content. - this._observableFromEvent(element, 'focus', {capture: true}) + fromEventPattern( + handler => element.addEventListener('focus', handler, true), + handler => element.removeEventListener('focus', handler, true), + ) .pipe(toClosest(ROW_SELECTOR), share(), takeUntil(this.destroyed)) .subscribe(this.editEventDispatcher.focused); merge( - this._observableFromEvent(element, 'blur', {capture: true}), - this._observableFromEvent(element, 'keydown').pipe( - filter(event => event.key === 'Escape'), + fromEventPattern( + handler => element.addEventListener('blur', handler, true), + handler => element.removeEventListener('blur', handler, true), ), + fromEvent(element, 'keydown').pipe(filter(event => event.key === 'Escape')), ) .pipe(mapTo(null), share(), takeUntil(this.destroyed)) .subscribe(this.editEventDispatcher.focused); @@ -167,7 +150,7 @@ export class CdkEditable implements AfterViewInit, OnDestroy { ) .subscribe(this.editEventDispatcher.allRows); - this._observableFromEvent(element, 'keydown') + fromEvent(element, 'keydown') .pipe( filter(event => event.key === 'Enter'), toClosest(CELL_SELECTOR), @@ -176,7 +159,7 @@ export class CdkEditable implements AfterViewInit, OnDestroy { .subscribe(this.editEventDispatcher.editing); // Keydown must be used here or else key auto-repeat does not work properly on some platforms. - this._observableFromEvent(element, 'keydown') + fromEvent(element, 'keydown') .pipe(takeUntil(this.destroyed)) .subscribe(this.focusDispatcher.keyObserver); }); diff --git a/src/cdk/a11y/focus-monitor/focus-monitor.ts b/src/cdk/a11y/focus-monitor/focus-monitor.ts index 017a4575a41f..ef34ed52694b 100644 --- a/src/cdk/a11y/focus-monitor/focus-monitor.ts +++ b/src/cdk/a11y/focus-monitor/focus-monitor.ts @@ -8,9 +8,9 @@ import { Platform, + normalizePassiveListenerOptions, _getShadowRoot, _getEventTarget, - _bindEventWithOptions, } from '@angular/cdk/platform'; import { Directive, @@ -23,7 +23,6 @@ import { Output, AfterViewInit, inject, - RendererFactory2, } from '@angular/core'; import {Observable, of as observableOf, Subject, Subscription} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; @@ -77,18 +76,16 @@ type MonitoredElementInfo = { * Event listener options that enable capturing and also * mark the listener as passive if the browser supports it. */ -const captureEventListenerOptions = { +const captureEventListenerOptions = normalizePassiveListenerOptions({ passive: true, capture: true, -}; +}); /** Monitors mouse and keyboard events to determine the cause of focus events. */ @Injectable({providedIn: 'root'}) export class FocusMonitor implements OnDestroy { private _ngZone = inject(NgZone); private _platform = inject(Platform); - private _renderer = inject(RendererFactory2).createRenderer(null, null); - private _cleanupWindowFocus: (() => void) | undefined; private readonly _inputModalityDetector = inject(InputModalityDetector); /** The focus origin that the next focus event is a result of. */ @@ -124,13 +121,7 @@ export class FocusMonitor implements OnDestroy { * handlers differently from the rest of the events, because the browser won't emit events * to the document when focus moves inside of a shadow root. */ - private _rootNodeFocusListeners = new Map< - HTMLElement | Document | ShadowRoot, - { - count: number; - cleanups: (() => void)[]; - } - >(); + private _rootNodeFocusListenerCount = new Map(); /** * The specified detection mode, used for attributing the origin of a focus @@ -316,6 +307,12 @@ export class FocusMonitor implements OnDestroy { return this._document || document; } + /** Use defaultView of injected document if available or fallback to global window reference */ + private _getWindow(): Window { + const doc = this._getDocument(); + return doc.defaultView || window; + } + private _getFocusOrigin(focusEventTarget: HTMLElement | null): FocusOrigin { if (this._origin) { // If the origin was realized via a touch interaction, we need to perform additional checks @@ -471,45 +468,32 @@ export class FocusMonitor implements OnDestroy { } const rootNode = elementInfo.rootNode; - const listeners = this._rootNodeFocusListeners.get(rootNode); + const rootNodeFocusListeners = this._rootNodeFocusListenerCount.get(rootNode) || 0; - if (listeners) { - listeners.count++; - } else { + if (!rootNodeFocusListeners) { this._ngZone.runOutsideAngular(() => { - this._rootNodeFocusListeners.set(rootNode, { - count: 1, - cleanups: [ - _bindEventWithOptions( - this._renderer, - rootNode, - 'focus', - this._rootNodeFocusAndBlurListener, - captureEventListenerOptions, - ), - _bindEventWithOptions( - this._renderer, - rootNode, - 'blur', - this._rootNodeFocusAndBlurListener, - captureEventListenerOptions, - ), - ], - }); + rootNode.addEventListener( + 'focus', + this._rootNodeFocusAndBlurListener, + captureEventListenerOptions, + ); + rootNode.addEventListener( + 'blur', + this._rootNodeFocusAndBlurListener, + captureEventListenerOptions, + ); }); } + this._rootNodeFocusListenerCount.set(rootNode, rootNodeFocusListeners + 1); + // Register global listeners when first element is monitored. if (++this._monitoredElementCount === 1) { // Note: we listen to events in the capture phase so we // can detect them even if the user stops propagation. this._ngZone.runOutsideAngular(() => { - this._cleanupWindowFocus?.(); - this._cleanupWindowFocus = this._renderer.listen( - 'window', - 'focus', - this._windowFocusListener, - ); + const window = this._getWindow(); + window.addEventListener('focus', this._windowFocusListener); }); // The InputModalityDetector is also just a collection of global listeners. @@ -522,20 +506,32 @@ export class FocusMonitor implements OnDestroy { } private _removeGlobalListeners(elementInfo: MonitoredElementInfo) { - const listeners = this._rootNodeFocusListeners.get(elementInfo.rootNode); + const rootNode = elementInfo.rootNode; - if (listeners) { - if (listeners.count > 1) { - listeners.count--; + if (this._rootNodeFocusListenerCount.has(rootNode)) { + const rootNodeFocusListeners = this._rootNodeFocusListenerCount.get(rootNode)!; + + if (rootNodeFocusListeners > 1) { + this._rootNodeFocusListenerCount.set(rootNode, rootNodeFocusListeners - 1); } else { - listeners.cleanups.forEach(cleanup => cleanup()); - this._rootNodeFocusListeners.delete(elementInfo.rootNode); + rootNode.removeEventListener( + 'focus', + this._rootNodeFocusAndBlurListener, + captureEventListenerOptions, + ); + rootNode.removeEventListener( + 'blur', + this._rootNodeFocusAndBlurListener, + captureEventListenerOptions, + ); + this._rootNodeFocusListenerCount.delete(rootNode); } } // Unregister global listeners when last element is unmonitored. if (!--this._monitoredElementCount) { - this._cleanupWindowFocus?.(); + const window = this._getWindow(); + window.removeEventListener('focus', this._windowFocusListener); // Equivalently, stop our InputModalityDetector subscription. this._stopInputModalityDetector.next(); diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index 87cebaf4fd7a..a8b0cb4ad633 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -7,15 +7,8 @@ */ import {ALT, CONTROL, MAC_META, META, SHIFT} from '@angular/cdk/keycodes'; -import { - Injectable, - InjectionToken, - OnDestroy, - NgZone, - inject, - RendererFactory2, -} from '@angular/core'; -import {Platform, _bindEventWithOptions, _getEventTarget} from '@angular/cdk/platform'; +import {Injectable, InjectionToken, OnDestroy, NgZone, inject} from '@angular/core'; +import {normalizePassiveListenerOptions, Platform, _getEventTarget} from '@angular/cdk/platform'; import {DOCUMENT} from '@angular/common'; import {BehaviorSubject, Observable} from 'rxjs'; import {distinctUntilChanged, skip} from 'rxjs/operators'; @@ -76,10 +69,10 @@ export const TOUCH_BUFFER_MS = 650; * Event listener options that enable capturing and also mark the listener as passive if the browser * supports it. */ -const modalityEventListenerOptions = { +const modalityEventListenerOptions = normalizePassiveListenerOptions({ passive: true, capture: true, -}; +}); /** * Service that detects the user's input modality. @@ -98,7 +91,6 @@ const modalityEventListenerOptions = { @Injectable({providedIn: 'root'}) export class InputModalityDetector implements OnDestroy { private readonly _platform = inject(Platform); - private readonly _listenerCleanups: (() => void)[] | undefined; /** Emits whenever an input modality is detected. */ readonly modalityDetected: Observable; @@ -201,38 +193,21 @@ export class InputModalityDetector implements OnDestroy { // If we're not in a browser, this service should do nothing, as there's no relevant input // modality to detect. if (this._platform.isBrowser) { - const renderer = inject(RendererFactory2).createRenderer(null, null); - - this._listenerCleanups = ngZone.runOutsideAngular(() => { - return [ - _bindEventWithOptions( - renderer, - document, - 'keydown', - this._onKeydown, - modalityEventListenerOptions, - ), - _bindEventWithOptions( - renderer, - document, - 'mousedown', - this._onMousedown, - modalityEventListenerOptions, - ), - _bindEventWithOptions( - renderer, - document, - 'touchstart', - this._onTouchstart, - modalityEventListenerOptions, - ), - ]; + ngZone.runOutsideAngular(() => { + document.addEventListener('keydown', this._onKeydown, modalityEventListenerOptions); + document.addEventListener('mousedown', this._onMousedown, modalityEventListenerOptions); + document.addEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions); }); } } ngOnDestroy() { this._modality.complete(); - this._listenerCleanups?.forEach(cleanup => cleanup()); + + if (this._platform.isBrowser) { + document.removeEventListener('keydown', this._onKeydown, modalityEventListenerOptions); + document.removeEventListener('mousedown', this._onMousedown, modalityEventListenerOptions); + document.removeEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions); + } } } diff --git a/src/cdk/drag-drop/drag-drop-registry.ts b/src/cdk/drag-drop/drag-drop-registry.ts index f67043965418..5667c6f78a47 100644 --- a/src/cdk/drag-drop/drag-drop-registry.ts +++ b/src/cdk/drag-drop/drag-drop-registry.ts @@ -10,33 +10,26 @@ import { ChangeDetectionStrategy, Component, Injectable, - ListenerOptions, NgZone, OnDestroy, - RendererFactory2, ViewEncapsulation, WritableSignal, inject, signal, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; -import {_bindEventWithOptions} from '@angular/cdk/platform'; +import {normalizePassiveListenerOptions} from '@angular/cdk/platform'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; import {Observable, Observer, Subject, merge} from 'rxjs'; import type {DropListRef} from './drop-list-ref'; import type {DragRef} from './drag-ref'; import type {CdkDrag} from './directives/drag'; -/** Event options that can be used to bind a capturing event. */ -const capturingEventOptions = { - capture: true, -}; - /** Event options that can be used to bind an active, capturing event. */ -const activeCapturingEventOptions = { +const activeCapturingEventOptions = normalizePassiveListenerOptions({ passive: false, capture: true, -}; +}); /** * Component used to load the drag&drop reset styles. @@ -62,8 +55,6 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { private _ngZone = inject(NgZone); private _document = inject(DOCUMENT); private _styleLoader = inject(_CdkPrivateStyleLoader); - private _renderer = inject(RendererFactory2).createRenderer(null, null); - private _cleanupDocumentTouchmove: (() => void) | undefined; /** Registered drop container instances. */ private _dropInstances = new Set(); @@ -75,7 +66,13 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { private _activeDragInstances: WritableSignal = signal([]); /** Keeps track of the event listeners that we've bound to the `document`. */ - private _globalListeners: (() => void)[] | undefined; + private _globalListeners = new Map< + string, + { + handler: (event: Event) => void; + options?: AddEventListenerOptions | boolean; + } + >(); /** * Predicate function to check if an item is being dragged. Moved out into a property, @@ -130,10 +127,7 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { this._ngZone.runOutsideAngular(() => { // The event handler has to be explicitly active, // because newer browsers make it passive by default. - this._cleanupDocumentTouchmove?.(); - this._cleanupDocumentTouchmove = _bindEventWithOptions( - this._renderer, - this._document, + this._document.addEventListener( 'touchmove', this._persistentTouchmoveListener, activeCapturingEventOptions, @@ -153,7 +147,11 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { this.stopDragging(drag); if (this._dragInstances.size === 0) { - this._cleanupDocumentTouchmove?.(); + this._document.removeEventListener( + 'touchmove', + this._persistentTouchmoveListener, + activeCapturingEventOptions, + ); } } @@ -176,43 +174,47 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { // passive ones for `mousemove` and `touchmove`. The events need to be active, because we // use `preventDefault` to prevent the page from scrolling while the user is dragging. const isTouchEvent = event.type.startsWith('touch'); - const endEventHandler = (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent); + const endEventHandler = { + handler: (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent), + options: true, + }; - const toBind: [name: string, handler: (event: Event) => void, options: ListenerOptions][] = [ - // Use capturing so that we pick up scroll changes in any scrollable nodes that aren't - // the document. See https://github.com/angular/components/issues/17144. - ['scroll', (e: Event) => this.scroll.next(e), capturingEventOptions], + if (isTouchEvent) { + this._globalListeners.set('touchend', endEventHandler); + this._globalListeners.set('touchcancel', endEventHandler); + } else { + this._globalListeners.set('mouseup', endEventHandler); + } + this._globalListeners + .set('scroll', { + handler: (e: Event) => this.scroll.next(e), + // Use capturing so that we pick up scroll changes in any scrollable nodes that aren't + // the document. See https://github.com/angular/components/issues/17144. + options: true, + }) // Preventing the default action on `mousemove` isn't enough to disable text selection // on Safari so we need to prevent the selection event as well. Alternatively this can // be done by setting `user-select: none` on the `body`, however it has causes a style // recalculation which can be expensive on pages with a lot of elements. - ['selectstart', this._preventDefaultWhileDragging, activeCapturingEventOptions], - ]; - - if (isTouchEvent) { - toBind.push( - ['touchend', endEventHandler, capturingEventOptions], - ['touchcancel', endEventHandler, capturingEventOptions], - ); - } else { - toBind.push(['mouseup', endEventHandler, capturingEventOptions]); - } + .set('selectstart', { + handler: this._preventDefaultWhileDragging, + options: activeCapturingEventOptions, + }); // We don't have to bind a move event for touch drag sequences, because // we already have a persistent global one bound from `registerDragItem`. if (!isTouchEvent) { - toBind.push([ - 'mousemove', - (e: Event) => this.pointerMove.next(e as MouseEvent), - activeCapturingEventOptions, - ]); + this._globalListeners.set('mousemove', { + handler: (e: Event) => this.pointerMove.next(e as MouseEvent), + options: activeCapturingEventOptions, + }); } this._ngZone.runOutsideAngular(() => { - this._globalListeners = toBind.map(([name, handler, options]) => - _bindEventWithOptions(this._renderer, this._document, name, handler, options), - ); + this._globalListeners.forEach((config, name) => { + this._document.addEventListener(name, config.handler, config.options); + }); }); } } @@ -255,20 +257,17 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { streams.push( new Observable((observer: Observer) => { return this._ngZone.runOutsideAngular(() => { - const cleanup = _bindEventWithOptions( - this._renderer, - shadowRoot as ShadowRoot, - 'scroll', - (event: Event) => { - if (this._activeDragInstances().length) { - observer.next(event); - } - }, - capturingEventOptions, - ); + const eventOptions = true; + const callback = (event: Event) => { + if (this._activeDragInstances().length) { + observer.next(event); + } + }; + + (shadowRoot as ShadowRoot).addEventListener('scroll', callback, eventOptions); return () => { - cleanup(); + (shadowRoot as ShadowRoot).removeEventListener('scroll', callback, eventOptions); }; }); }), @@ -339,7 +338,10 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { /** Clears out the global event listeners from the `document`. */ private _clearGlobalListeners() { - this._globalListeners?.forEach(cleanup => cleanup()); - this._globalListeners = undefined; + this._globalListeners.forEach((config, name) => { + this._document.removeEventListener(name, config.handler, config.options); + }); + + this._globalListeners.clear(); } } diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index 9bcb2e9d5133..0d20f2aa38e6 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -9,7 +9,11 @@ import {isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader} from '@angular/cdk/a11y'; import {Direction} from '@angular/cdk/bidi'; import {coerceElement} from '@angular/cdk/coercion'; -import {_getEventTarget, _getShadowRoot, _bindEventWithOptions} from '@angular/cdk/platform'; +import { + _getEventTarget, + _getShadowRoot, + normalizePassiveListenerOptions, +} from '@angular/cdk/platform'; import {ViewportRuler} from '@angular/cdk/scrolling'; import { ElementRef, @@ -58,16 +62,16 @@ export interface DragRefConfig { } /** Options that can be used to bind a passive event listener. */ -const passiveEventListenerOptions = {passive: true}; +const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: true}); /** Options that can be used to bind an active event listener. */ -const activeEventListenerOptions = {passive: false}; +const activeEventListenerOptions = normalizePassiveListenerOptions({passive: false}); /** Event options that can be used to bind an active, capturing event. */ -const activeCapturingEventOptions = { +const activeCapturingEventOptions = normalizePassiveListenerOptions({ passive: false, capture: true, -}; +}); /** * Time in milliseconds for which to ignore mouse events, after @@ -117,9 +121,6 @@ export type PreviewContainer = 'global' | 'parent' | ElementRef | H * Reference to a draggable item. Used to manipulate or dispose of the item. */ export class DragRef { - private _rootElementCleanups: (() => void)[] | undefined; - private _cleanupShadowRootSelectStart: (() => void) | undefined; - /** Element displayed next to the user's pointer while the element is dragged. */ private _preview: PreviewRef | null; @@ -453,30 +454,15 @@ export class DragRef { const element = coerceElement(rootElement); if (element !== this._rootElement) { - this._removeRootElementListeners(); - this._rootElementCleanups = this._ngZone.runOutsideAngular(() => [ - _bindEventWithOptions( - this._renderer, - element, - 'mousedown', - this._pointerDown, - activeEventListenerOptions, - ), - _bindEventWithOptions( - this._renderer, - element, - 'touchstart', - this._pointerDown, - passiveEventListenerOptions, - ), - _bindEventWithOptions( - this._renderer, - element, - 'dragstart', - this._nativeDragStart, - activeEventListenerOptions, - ), - ]); + if (this._rootElement) { + this._removeRootElementListeners(this._rootElement); + } + + this._ngZone.runOutsideAngular(() => { + element.addEventListener('mousedown', this._pointerDown, activeEventListenerOptions); + element.addEventListener('touchstart', this._pointerDown, passiveEventListenerOptions); + element.addEventListener('dragstart', this._nativeDragStart, activeEventListenerOptions); + }); this._initialTransform = undefined; this._rootElement = element; } @@ -510,7 +496,7 @@ export class DragRef { /** Removes the dragging functionality from the DOM element. */ dispose() { - this._removeRootElementListeners(); + this._removeRootElementListeners(this._rootElement); // Do this check before removing from the registry since it'll // stop being considered as dragged once it is removed. @@ -640,8 +626,11 @@ export class DragRef { this._pointerMoveSubscription.unsubscribe(); this._pointerUpSubscription.unsubscribe(); this._scrollSubscription.unsubscribe(); - this._cleanupShadowRootSelectStart?.(); - this._cleanupShadowRootSelectStart = undefined; + this._getShadowRoot()?.removeEventListener( + 'selectstart', + shadowDomSelectStart, + activeCapturingEventOptions, + ); } /** Destroys the preview element and its ViewRef. */ @@ -829,9 +818,7 @@ export class DragRef { // In some browsers the global `selectstart` that we maintain in the `DragDropRegistry` // doesn't cross the shadow boundary so we have to prevent it at the shadow root (see #28792). this._ngZone.runOutsideAngular(() => { - this._cleanupShadowRootSelectStart = _bindEventWithOptions( - this._renderer, - shadowRoot, + shadowRoot.addEventListener( 'selectstart', shadowDomSelectStart, activeCapturingEventOptions, @@ -1303,9 +1290,10 @@ export class DragRef { } /** Removes the manually-added event listeners from the root element. */ - private _removeRootElementListeners() { - this._rootElementCleanups?.forEach(cleanup => cleanup()); - this._rootElementCleanups = undefined; + private _removeRootElementListeners(element: HTMLElement) { + element.removeEventListener('mousedown', this._pointerDown, activeEventListenerOptions); + element.removeEventListener('touchstart', this._pointerDown, passiveEventListenerOptions); + element.removeEventListener('dragstart', this._nativeDragStart, activeEventListenerOptions); } /** diff --git a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts index 9691374ed25c..afc1f93512b6 100644 --- a/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts +++ b/src/cdk/overlay/dispatchers/overlay-keyboard-dispatcher.spec.ts @@ -142,13 +142,12 @@ describe('OverlayKeyboardDispatcher', () => { spyOn(body, 'removeEventListener'); keyboardDispatcher.add(overlayRef); - expect(body.addEventListener).toHaveBeenCalledWith('keydown', jasmine.any(Function), undefined); + expect(body.addEventListener).toHaveBeenCalledWith('keydown', jasmine.any(Function), false); overlayRef.dispose(); expect(document.body.removeEventListener).toHaveBeenCalledWith( 'keydown', jasmine.any(Function), - undefined, ); }); diff --git a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts index b866be5c91bd..e97458f96a17 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts @@ -138,18 +138,10 @@ describe('OverlayOutsideClickDispatcher', () => { spyOn(body, 'removeEventListener'); outsideClickDispatcher.add(overlayRef); - expect(body.addEventListener).toHaveBeenCalledWith( - 'click', - jasmine.any(Function), - jasmine.objectContaining({capture: true}), - ); + expect(body.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), true); overlayRef.dispose(); - expect(body.removeEventListener).toHaveBeenCalledWith( - 'click', - jasmine.any(Function), - jasmine.objectContaining({capture: true}), - ); + expect(body.removeEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), true); }); it('should not add the same overlay to the stack multiple times', () => { diff --git a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts index daef17c474de..5fe85850620a 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Injectable, NgZone, RendererFactory2, inject} from '@angular/core'; -import {Platform, _bindEventWithOptions, _getEventTarget} from '@angular/cdk/platform'; +import {Injectable, NgZone, inject} from '@angular/core'; +import {Platform, _getEventTarget} from '@angular/cdk/platform'; import {BaseOverlayDispatcher} from './base-overlay-dispatcher'; import type {OverlayRef} from '../overlay-ref'; @@ -19,13 +19,11 @@ import type {OverlayRef} from '../overlay-ref'; @Injectable({providedIn: 'root'}) export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { private _platform = inject(Platform); - private _ngZone = inject(NgZone); - private _renderer = inject(RendererFactory2).createRenderer(null, null); + private _ngZone = inject(NgZone, {optional: true}); private _cursorOriginalValue: string; private _cursorStyleIsSet = false; private _pointerDownEventTarget: HTMLElement | null; - private _cleanups: (() => void)[] | undefined; /** Add a new overlay to the list of attached overlay refs. */ override add(overlayRef: OverlayRef): void { @@ -39,26 +37,13 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { // https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html if (!this._isAttached) { const body = this._document.body; - const eventOptions = {capture: true}; - - this._cleanups = this._ngZone.runOutsideAngular(() => [ - _bindEventWithOptions( - this._renderer, - body, - 'pointerdown', - this._pointerDownListener, - eventOptions, - ), - _bindEventWithOptions(this._renderer, body, 'click', this._clickListener, eventOptions), - _bindEventWithOptions(this._renderer, body, 'auxclick', this._clickListener, eventOptions), - _bindEventWithOptions( - this._renderer, - body, - 'contextmenu', - this._clickListener, - eventOptions, - ), - ]); + + /** @breaking-change 14.0.0 _ngZone will be required. */ + if (this._ngZone) { + this._ngZone.runOutsideAngular(() => this._addEventListeners(body)); + } else { + this._addEventListeners(body); + } // click event is not fired on iOS. To make element "clickable" we are // setting the cursor to pointer @@ -75,16 +60,26 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { /** Detaches the global keyboard event listener. */ protected detach() { if (this._isAttached) { - this._cleanups?.forEach(cleanup => cleanup()); - this._cleanups = undefined; + const body = this._document.body; + body.removeEventListener('pointerdown', this._pointerDownListener, true); + body.removeEventListener('click', this._clickListener, true); + body.removeEventListener('auxclick', this._clickListener, true); + body.removeEventListener('contextmenu', this._clickListener, true); if (this._platform.IOS && this._cursorStyleIsSet) { - this._document.body.style.cursor = this._cursorOriginalValue; + body.style.cursor = this._cursorOriginalValue; this._cursorStyleIsSet = false; } this._isAttached = false; } } + private _addEventListeners(body: HTMLElement): void { + body.addEventListener('pointerdown', this._pointerDownListener, true); + body.addEventListener('click', this._clickListener, true); + body.addEventListener('auxclick', this._clickListener, true); + body.addEventListener('contextmenu', this._clickListener, true); + } + /** Store pointerdown event target to track origin of click. */ private _pointerDownListener = (event: PointerEvent) => { this._pointerDownEventTarget = _getEventTarget(event); diff --git a/src/cdk/text-field/autofill.ts b/src/cdk/text-field/autofill.ts index 23b7ea72883c..9586a2b9badf 100644 --- a/src/cdk/text-field/autofill.ts +++ b/src/cdk/text-field/autofill.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Platform, _bindEventWithOptions} from '@angular/cdk/platform'; +import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; import { Directive, ElementRef, @@ -17,7 +17,6 @@ import { OnDestroy, OnInit, Output, - RendererFactory2, } from '@angular/core'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; import {coerceElement} from '@angular/cdk/coercion'; @@ -39,7 +38,7 @@ type MonitoredElementInfo = { }; /** Options to pass to the animationstart listener. */ -const listenerOptions = {passive: true}; +const listenerOptions = normalizePassiveListenerOptions({passive: true}); /** * An injectable service that can be used to monitor the autofill state of an input. @@ -50,7 +49,6 @@ const listenerOptions = {passive: true}; export class AutofillMonitor implements OnDestroy { private _platform = inject(Platform); private _ngZone = inject(NgZone); - private _renderer = inject(RendererFactory2).createRenderer(null, null); private _styleLoader = inject(_CdkPrivateStyleLoader); private _monitoredElements = new Map(); @@ -86,9 +84,9 @@ export class AutofillMonitor implements OnDestroy { return info.subject; } - const subject = new Subject(); + const result = new Subject(); const cssClass = 'cdk-text-field-autofilled'; - const listener = (event: AnimationEvent) => { + const listener = ((event: AnimationEvent) => { // Animation events fire on initial element render, we check for the presence of the autofill // CSS class to make sure this is a real change in state, not just the initial render before // we fire off events. @@ -97,31 +95,29 @@ export class AutofillMonitor implements OnDestroy { !element.classList.contains(cssClass) ) { element.classList.add(cssClass); - this._ngZone.run(() => subject.next({target: event.target as Element, isAutofilled: true})); + this._ngZone.run(() => result.next({target: event.target as Element, isAutofilled: true})); } else if ( event.animationName === 'cdk-text-field-autofill-end' && element.classList.contains(cssClass) ) { element.classList.remove(cssClass); - this._ngZone.run(() => - subject.next({target: event.target as Element, isAutofilled: false}), - ); + this._ngZone.run(() => result.next({target: event.target as Element, isAutofilled: false})); } - }; + }) as EventListenerOrEventListenerObject; - const unlisten = this._ngZone.runOutsideAngular(() => { + this._ngZone.runOutsideAngular(() => { + element.addEventListener('animationstart', listener, listenerOptions); element.classList.add('cdk-text-field-autofill-monitored'); - return _bindEventWithOptions( - this._renderer, - element, - 'animationstart', - listener, - listenerOptions, - ); }); - this._monitoredElements.set(element, {subject, unlisten}); - return subject; + this._monitoredElements.set(element, { + subject: result, + unlisten: () => { + element.removeEventListener('animationstart', listener, listenerOptions); + }, + }); + + return result; } /** diff --git a/src/material/core/private/ripple-loader.ts b/src/material/core/private/ripple-loader.ts index 0b7c1d5ae4d4..c804b2e857d6 100644 --- a/src/material/core/private/ripple-loader.ts +++ b/src/material/core/private/ripple-loader.ts @@ -13,7 +13,6 @@ import { Injector, NgZone, OnDestroy, - RendererFactory2, inject, } from '@angular/core'; import { @@ -22,7 +21,7 @@ import { RippleTarget, defaultRippleAnimationConfig, } from '../ripple'; -import {Platform, _bindEventWithOptions, _getEventTarget} from '@angular/cdk/platform'; +import {Platform, _getEventTarget} from '@angular/cdk/platform'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; /** The options for the MatRippleLoader's event listeners. */ @@ -57,31 +56,22 @@ const matRippleDisabled = 'mat-ripple-loader-disabled'; */ @Injectable({providedIn: 'root'}) export class MatRippleLoader implements OnDestroy { - private _document = inject(DOCUMENT); + private _document = inject(DOCUMENT, {optional: true}); private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true}); private _globalRippleOptions = inject(MAT_RIPPLE_GLOBAL_OPTIONS, {optional: true}); private _platform = inject(Platform); private _ngZone = inject(NgZone); private _injector = inject(Injector); - private _eventCleanups: (() => void)[]; private _hosts = new Map< HTMLElement, {renderer: RippleRenderer; target: RippleTarget; hasSetUpEvents: boolean} >(); constructor() { - const renderer = inject(RendererFactory2).createRenderer(null, null); - - this._eventCleanups = this._ngZone.runOutsideAngular(() => { - return rippleInteractionEvents.map(name => - _bindEventWithOptions( - renderer, - this._document, - name, - this._onInteraction, - eventListenerOptions, - ), - ); + this._ngZone.runOutsideAngular(() => { + for (const event of rippleInteractionEvents) { + this._document?.addEventListener(event, this._onInteraction, eventListenerOptions); + } }); } @@ -92,7 +82,9 @@ export class MatRippleLoader implements OnDestroy { this.destroyRipple(host); } - this._eventCleanups.forEach(cleanup => cleanup()); + for (const event of rippleInteractionEvents) { + this._document?.removeEventListener(event, this._onInteraction, eventListenerOptions); + } } /** diff --git a/src/material/datepicker/calendar-body.ts b/src/material/datepicker/calendar-body.ts index a0620f72829f..7de7a8125adb 100644 --- a/src/material/datepicker/calendar-body.ts +++ b/src/material/datepicker/calendar-body.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Platform, _bindEventWithOptions} from '@angular/cdk/platform'; +import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; import { ChangeDetectionStrategy, Component, @@ -23,7 +23,6 @@ import { inject, afterNextRender, Injector, - Renderer2, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {NgClass} from '@angular/common'; @@ -67,19 +66,19 @@ export interface MatCalendarUserEvent { } /** Event options that can be used to bind an active, capturing event. */ -const activeCapturingEventOptions = { +const activeCapturingEventOptions = normalizePassiveListenerOptions({ passive: false, capture: true, -}; +}); /** Event options that can be used to bind a passive, capturing event. */ -const passiveCapturingEventOptions = { +const passiveCapturingEventOptions = normalizePassiveListenerOptions({ passive: true, capture: true, -}; +}); /** Event options that can be used to bind a passive, non-capturing event. */ -const passiveEventOptions = {passive: true}; +const passiveEventOptions = normalizePassiveListenerOptions({passive: true}); /** * An internal component used to display calendar data in a table. @@ -102,7 +101,6 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterView private _ngZone = inject(NgZone); private _platform = inject(Platform); private _intl = inject(MatDatepickerIntl); - private _eventCleanups: (() => void)[]; /** * Used to skip the next focus event when rendering the preview range. @@ -226,7 +224,6 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterView constructor(...args: unknown[]); constructor() { - const renderer = inject(Renderer2); const idGenerator = inject(_IdGenerator); this._startDateLabelId = idGenerator.getId('mat-calendar-body-start-'); this._endDateLabelId = idGenerator.getId('mat-calendar-body-end-'); @@ -237,67 +234,22 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterView this._ngZone.runOutsideAngular(() => { const element = this._elementRef.nativeElement; - const cleanups = [ - // `touchmove` is active since we need to call `preventDefault`. - _bindEventWithOptions( - renderer, - element, - 'touchmove', - this._touchmoveHandler, - activeCapturingEventOptions, - ), - _bindEventWithOptions( - renderer, - element, - 'mouseenter', - this._enterHandler, - passiveCapturingEventOptions, - ), - _bindEventWithOptions( - renderer, - element, - 'focus', - this._enterHandler, - passiveCapturingEventOptions, - ), - _bindEventWithOptions( - renderer, - element, - 'mouseleave', - this._leaveHandler, - passiveCapturingEventOptions, - ), - _bindEventWithOptions( - renderer, - element, - 'blur', - this._leaveHandler, - passiveCapturingEventOptions, - ), - _bindEventWithOptions( - renderer, - element, - 'mousedown', - this._mousedownHandler, - passiveEventOptions, - ), - _bindEventWithOptions( - renderer, - element, - 'touchstart', - this._mousedownHandler, - passiveEventOptions, - ), - ]; + + // `touchmove` is active since we need to call `preventDefault`. + element.addEventListener('touchmove', this._touchmoveHandler, activeCapturingEventOptions); + + element.addEventListener('mouseenter', this._enterHandler, passiveCapturingEventOptions); + element.addEventListener('focus', this._enterHandler, passiveCapturingEventOptions); + element.addEventListener('mouseleave', this._leaveHandler, passiveCapturingEventOptions); + element.addEventListener('blur', this._leaveHandler, passiveCapturingEventOptions); + + element.addEventListener('mousedown', this._mousedownHandler, passiveEventOptions); + element.addEventListener('touchstart', this._mousedownHandler, passiveEventOptions); if (this._platform.isBrowser) { - cleanups.push( - renderer.listen('window', 'mouseup', this._mouseupHandler), - renderer.listen('window', 'touchend', this._touchendHandler), - ); + window.addEventListener('mouseup', this._mouseupHandler); + window.addEventListener('touchend', this._touchendHandler); } - - this._eventCleanups = cleanups; }); } @@ -343,7 +295,22 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterView } ngOnDestroy() { - this._eventCleanups.forEach(cleanup => cleanup()); + const element = this._elementRef.nativeElement; + + element.removeEventListener('touchmove', this._touchmoveHandler, activeCapturingEventOptions); + + element.removeEventListener('mouseenter', this._enterHandler, passiveCapturingEventOptions); + element.removeEventListener('focus', this._enterHandler, passiveCapturingEventOptions); + element.removeEventListener('mouseleave', this._leaveHandler, passiveCapturingEventOptions); + element.removeEventListener('blur', this._leaveHandler, passiveCapturingEventOptions); + + element.removeEventListener('mousedown', this._mousedownHandler, passiveEventOptions); + element.removeEventListener('touchstart', this._mousedownHandler, passiveEventOptions); + + if (this._platform.isBrowser) { + window.removeEventListener('mouseup', this._mouseupHandler); + window.removeEventListener('touchend', this._touchendHandler); + } } /** Returns whether a cell is active. */ diff --git a/src/material/menu/menu-trigger.ts b/src/material/menu/menu-trigger.ts index 80ec28647c43..e9fa1aa6cf17 100644 --- a/src/material/menu/menu-trigger.ts +++ b/src/material/menu/menu-trigger.ts @@ -36,10 +36,9 @@ import { NgZone, OnDestroy, Output, - Renderer2, ViewContainerRef, } from '@angular/core'; -import {_bindEventWithOptions} from '@angular/cdk/platform'; +import {normalizePassiveListenerOptions} from '@angular/cdk/platform'; import {merge, Observable, of as observableOf, Subscription} from 'rxjs'; import {filter, take, takeUntil} from 'rxjs/operators'; import {MatMenu, MenuCloseReason} from './menu'; @@ -73,7 +72,7 @@ export const MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER = { }; /** Options for binding a passive event listener. */ -const passiveEventListenerOptions = {passive: true}; +const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: true}); /** * Default top padding of the menu panel. @@ -109,7 +108,6 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { private _ngZone = inject(NgZone); private _scrollStrategy = inject(MAT_MENU_SCROLL_STRATEGY); private _changeDetectorRef = inject(ChangeDetectorRef); - private _cleanupTouchstart: () => void; private _portal: TemplatePortal; private _overlayRef: OverlayRef | null = null; @@ -131,6 +129,16 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { */ private _parentInnerPadding: number | undefined; + /** + * Handles touch start events on the trigger. + * Needs to be an arrow function so we can easily use addEventListener and removeEventListener. + */ + private _handleTouchStart = (event: TouchEvent) => { + if (!isFakeTouchstartFromScreenReader(event)) { + this._openedBy = 'touch'; + } + }; + // Tracking input type is necessary so it's possible to only auto-focus // the first item of the list when the menu is opened via the keyboard _openedBy: Exclude | undefined = undefined; @@ -215,18 +223,12 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { constructor() { const parentMenu = inject(MAT_MENU_PANEL, {optional: true}); - const renderer = inject(Renderer2); this._parentMaterialMenu = parentMenu instanceof MatMenu ? parentMenu : undefined; - this._cleanupTouchstart = _bindEventWithOptions( - renderer, - this._element.nativeElement, + + this._element.nativeElement.addEventListener( 'touchstart', - (event: TouchEvent) => { - if (!isFakeTouchstartFromScreenReader(event)) { - this._openedBy = 'touch'; - } - }, + this._handleTouchStart, passiveEventListenerOptions, ); } @@ -240,7 +242,12 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { PANELS_TO_TRIGGERS.delete(this.menu); } - this._cleanupTouchstart(); + this._element.nativeElement.removeEventListener( + 'touchstart', + this._handleTouchStart, + passiveEventListenerOptions, + ); + this._pendingRemoval?.unsubscribe(); this._menuCloseSubscription.unsubscribe(); this._closingActionsSubscription.unsubscribe(); diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index 8e4b85bb6756..8d8053b16cb0 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -31,10 +31,9 @@ import { ANIMATION_MODULE_TYPE, afterNextRender, Injector, - Renderer2, } from '@angular/core'; import {DOCUMENT, NgClass} from '@angular/common'; -import {_bindEventWithOptions, Platform} from '@angular/cdk/platform'; +import {normalizePassiveListenerOptions, Platform} from '@angular/cdk/platform'; import {AriaDescriber, FocusMonitor} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import { @@ -164,7 +163,7 @@ export const TOOLTIP_PANEL_CLASS = 'mat-mdc-tooltip-panel'; const PANEL_CLASS = 'tooltip-panel'; /** Options used to bind passive event listeners. */ -const passiveListenerOptions = {passive: true}; +const passiveListenerOptions = normalizePassiveListenerOptions({passive: true}); // These constants were taken from MDC's `numbers` object. We can't import them from MDC, // because they have some top-level references to `window` which break during SSR. @@ -198,8 +197,6 @@ export class MatTooltip implements OnDestroy, AfterViewInit { private _focusMonitor = inject(FocusMonitor); protected _dir = inject(Directionality); private _injector = inject(Injector); - private _document = inject(DOCUMENT); - private _renderer = inject(Renderer2); private _defaultOptions = inject(MAT_TOOLTIP_DEFAULT_OPTIONS, { optional: true, }); @@ -360,8 +357,12 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } } - /** Cleanup functions for manually-bound events. */ - private readonly _eventCleanups: (() => void)[] = []; + /** Manually-bound passive event listeners. */ + private readonly _passiveListeners: (readonly [string, EventListenerOrEventListenerObject])[] = + []; + + /** Reference to the current document. */ + private _document = inject(DOCUMENT); /** Timer started at the last `touchstart` event. */ private _touchstartTimeout: null | ReturnType = null; @@ -438,8 +439,11 @@ export class MatTooltip implements OnDestroy, AfterViewInit { this._tooltipInstance = null; } - this._eventCleanups.forEach(cleanup => cleanup()); - this._eventCleanups.length = 0; + // Clean up the event listeners set in the constructor + this._passiveListeners.forEach(([event, listener]) => { + nativeElement.removeEventListener(event, listener, passiveListenerOptions); + }); + this._passiveListeners.length = 0; this._destroyed.next(); this._destroyed.complete(); @@ -762,59 +766,54 @@ export class MatTooltip implements OnDestroy, AfterViewInit { /** Binds the pointer events to the tooltip trigger. */ private _setupPointerEnterEventsIfNeeded() { // Optimization: Defer hooking up events if there's no message or the tooltip is disabled. - if (this._disabled || !this.message || !this._viewInitialized || this._eventCleanups.length) { + if ( + this._disabled || + !this.message || + !this._viewInitialized || + this._passiveListeners.length + ) { return; } - const element = this._elementRef.nativeElement; - // The mouse events shouldn't be bound on mobile devices, because they can prevent the // first tap from firing its click event or can cause the tooltip to open for clicks. if (this._platformSupportsMouseEvents()) { - this._eventCleanups.push( - _bindEventWithOptions( - this._renderer, - element, - 'mouseenter', - (event: MouseEvent) => { - this._setupPointerExitEventsIfNeeded(); - let point = undefined; - if (event.x !== undefined && event.y !== undefined) { - point = event; - } - this.show(undefined, point); - }, - passiveListenerOptions, - ), - ); + this._passiveListeners.push([ + 'mouseenter', + event => { + this._setupPointerExitEventsIfNeeded(); + let point = undefined; + if ((event as MouseEvent).x !== undefined && (event as MouseEvent).y !== undefined) { + point = event as MouseEvent; + } + this.show(undefined, point); + }, + ]); } else if (this.touchGestures !== 'off') { this._disableNativeGesturesIfNecessary(); - this._eventCleanups.push( - _bindEventWithOptions( - this._renderer, - element, - 'touchstart', - (event: TouchEvent) => { - const touch = event.targetTouches?.[0]; - const origin = touch ? {x: touch.clientX, y: touch.clientY} : undefined; - // Note that it's important that we don't `preventDefault` here, - // because it can prevent click events from firing on the element. - this._setupPointerExitEventsIfNeeded(); - if (this._touchstartTimeout) { - clearTimeout(this._touchstartTimeout); - } + this._passiveListeners.push([ + 'touchstart', + event => { + const touch = (event as TouchEvent).targetTouches?.[0]; + const origin = touch ? {x: touch.clientX, y: touch.clientY} : undefined; + // Note that it's important that we don't `preventDefault` here, + // because it can prevent click events from firing on the element. + this._setupPointerExitEventsIfNeeded(); + if (this._touchstartTimeout) { + clearTimeout(this._touchstartTimeout); + } - const DEFAULT_LONGPRESS_DELAY = 500; - this._touchstartTimeout = setTimeout(() => { - this._touchstartTimeout = null; - this.show(undefined, origin); - }, this._defaultOptions?.touchLongPressShowDelay ?? DEFAULT_LONGPRESS_DELAY); - }, - passiveListenerOptions, - ), - ); + const DEFAULT_LONGPRESS_DELAY = 500; + this._touchstartTimeout = setTimeout(() => { + this._touchstartTimeout = null; + this.show(undefined, origin); + }, this._defaultOptions?.touchLongPressShowDelay ?? DEFAULT_LONGPRESS_DELAY); + }, + ]); } + + this._addListeners(this._passiveListeners); } private _setupPointerExitEventsIfNeeded() { @@ -823,29 +822,19 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } this._pointerExitEventsInitialized = true; - const element = this._elementRef.nativeElement; - + const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = []; if (this._platformSupportsMouseEvents()) { - this._eventCleanups.push( - _bindEventWithOptions( - this._renderer, - element, + exitListeners.push( + [ 'mouseleave', - (event: MouseEvent) => { - const newTarget = event.relatedTarget as Node | null; + event => { + const newTarget = (event as MouseEvent).relatedTarget as Node | null; if (!newTarget || !this._overlayRef?.overlayElement.contains(newTarget)) { this.hide(); } }, - passiveListenerOptions, - ), - _bindEventWithOptions( - this._renderer, - element, - 'wheel', - event => this._wheelListener(event), - passiveListenerOptions, - ), + ], + ['wheel', event => this._wheelListener(event as WheelEvent)], ); } else if (this.touchGestures !== 'off') { this._disableNativeGesturesIfNecessary(); @@ -856,23 +845,17 @@ export class MatTooltip implements OnDestroy, AfterViewInit { this.hide(this._defaultOptions?.touchendHideDelay); }; - this._eventCleanups.push( - _bindEventWithOptions( - this._renderer, - element, - 'touchend', - touchendListener, - passiveListenerOptions, - ), - _bindEventWithOptions( - this._renderer, - element, - 'touchcancel', - touchendListener, - passiveListenerOptions, - ), - ); + exitListeners.push(['touchend', touchendListener], ['touchcancel', touchendListener]); } + + this._addListeners(exitListeners); + this._passiveListeners.push(...exitListeners); + } + + private _addListeners(listeners: (readonly [string, EventListenerOrEventListenerObject])[]) { + listeners.forEach(([event, listener]) => { + this._elementRef.nativeElement.addEventListener(event, listener, passiveListenerOptions); + }); } private _platformSupportsMouseEvents() {