diff --git a/src/cdk-experimental/popover-edit/table-directives.ts b/src/cdk-experimental/popover-edit/table-directives.ts index 404f5273889c..e9daa273284d 100644 --- a/src/cdk-experimental/popover-edit/table-directives.ts +++ b/src/cdk-experimental/popover-edit/table-directives.ts @@ -19,8 +19,10 @@ import { TemplateRef, ViewContainerRef, inject, + Renderer2, + ListenerOptions, } from '@angular/core'; -import {fromEvent, fromEventPattern, merge, Subject} from 'rxjs'; +import {merge, Observable, Subject} from 'rxjs'; import { debounceTime, filter, @@ -44,6 +46,7 @@ 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 @@ -73,6 +76,7 @@ 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(); @@ -94,6 +98,23 @@ 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) => @@ -101,13 +122,13 @@ export class CdkEditable implements AfterViewInit, OnDestroy { this.ngZone.runOutsideAngular(() => { // Track mouse movement over the table to hide/show hover content. - fromEvent(element, 'mouseover') + this._observableFromEvent(element, 'mouseover') .pipe(toClosest(ROW_SELECTOR), takeUntil(this.destroyed)) .subscribe(this.editEventDispatcher.hovering); - fromEvent(element, 'mouseleave') + this._observableFromEvent(element, 'mouseleave') .pipe(mapTo(null), takeUntil(this.destroyed)) .subscribe(this.editEventDispatcher.hovering); - fromEvent(element, 'mousemove') + this._observableFromEvent(element, 'mousemove') .pipe( throttleTime(MOUSE_MOVE_THROTTLE_TIME_MS), toClosest(ROW_SELECTOR), @@ -116,19 +137,15 @@ export class CdkEditable implements AfterViewInit, OnDestroy { .subscribe(this.editEventDispatcher.mouseMove); // Track focus within the table to hide/show/make focusable hover content. - fromEventPattern( - handler => element.addEventListener('focus', handler, true), - handler => element.removeEventListener('focus', handler, true), - ) + this._observableFromEvent(element, 'focus', {capture: true}) .pipe(toClosest(ROW_SELECTOR), share(), takeUntil(this.destroyed)) .subscribe(this.editEventDispatcher.focused); merge( - fromEventPattern( - handler => element.addEventListener('blur', handler, true), - handler => element.removeEventListener('blur', handler, true), + this._observableFromEvent(element, 'blur', {capture: true}), + this._observableFromEvent(element, 'keydown').pipe( + filter(event => event.key === 'Escape'), ), - fromEvent(element, 'keydown').pipe(filter(event => event.key === 'Escape')), ) .pipe(mapTo(null), share(), takeUntil(this.destroyed)) .subscribe(this.editEventDispatcher.focused); @@ -150,7 +167,7 @@ export class CdkEditable implements AfterViewInit, OnDestroy { ) .subscribe(this.editEventDispatcher.allRows); - fromEvent(element, 'keydown') + this._observableFromEvent(element, 'keydown') .pipe( filter(event => event.key === 'Enter'), toClosest(CELL_SELECTOR), @@ -159,7 +176,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. - fromEvent(element, 'keydown') + this._observableFromEvent(element, 'keydown') .pipe(takeUntil(this.destroyed)) .subscribe(this.focusDispatcher.keyObserver); }); diff --git a/src/cdk/a11y/input-modality/input-modality-detector.ts b/src/cdk/a11y/input-modality/input-modality-detector.ts index a8b0cb4ad633..87cebaf4fd7a 100644 --- a/src/cdk/a11y/input-modality/input-modality-detector.ts +++ b/src/cdk/a11y/input-modality/input-modality-detector.ts @@ -7,8 +7,15 @@ */ import {ALT, CONTROL, MAC_META, META, SHIFT} from '@angular/cdk/keycodes'; -import {Injectable, InjectionToken, OnDestroy, NgZone, inject} from '@angular/core'; -import {normalizePassiveListenerOptions, Platform, _getEventTarget} from '@angular/cdk/platform'; +import { + Injectable, + InjectionToken, + OnDestroy, + NgZone, + inject, + RendererFactory2, +} from '@angular/core'; +import {Platform, _bindEventWithOptions, _getEventTarget} from '@angular/cdk/platform'; import {DOCUMENT} from '@angular/common'; import {BehaviorSubject, Observable} from 'rxjs'; import {distinctUntilChanged, skip} from 'rxjs/operators'; @@ -69,10 +76,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 = normalizePassiveListenerOptions({ +const modalityEventListenerOptions = { passive: true, capture: true, -}); +}; /** * Service that detects the user's input modality. @@ -91,6 +98,7 @@ const modalityEventListenerOptions = normalizePassiveListenerOptions({ @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; @@ -193,21 +201,38 @@ 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) { - ngZone.runOutsideAngular(() => { - document.addEventListener('keydown', this._onKeydown, modalityEventListenerOptions); - document.addEventListener('mousedown', this._onMousedown, modalityEventListenerOptions); - document.addEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions); + 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, + ), + ]; }); } } ngOnDestroy() { this._modality.complete(); - - if (this._platform.isBrowser) { - document.removeEventListener('keydown', this._onKeydown, modalityEventListenerOptions); - document.removeEventListener('mousedown', this._onMousedown, modalityEventListenerOptions); - document.removeEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions); - } + this._listenerCleanups?.forEach(cleanup => cleanup()); } } diff --git a/src/cdk/drag-drop/drag-drop-registry.ts b/src/cdk/drag-drop/drag-drop-registry.ts index 5667c6f78a47..f67043965418 100644 --- a/src/cdk/drag-drop/drag-drop-registry.ts +++ b/src/cdk/drag-drop/drag-drop-registry.ts @@ -10,26 +10,33 @@ import { ChangeDetectionStrategy, Component, Injectable, + ListenerOptions, NgZone, OnDestroy, + RendererFactory2, ViewEncapsulation, WritableSignal, inject, signal, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; -import {normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {_bindEventWithOptions} 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 = normalizePassiveListenerOptions({ +const activeCapturingEventOptions = { passive: false, capture: true, -}); +}; /** * Component used to load the drag&drop reset styles. @@ -55,6 +62,8 @@ 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(); @@ -66,13 +75,7 @@ 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 = new Map< - string, - { - handler: (event: Event) => void; - options?: AddEventListenerOptions | boolean; - } - >(); + private _globalListeners: (() => void)[] | undefined; /** * Predicate function to check if an item is being dragged. Moved out into a property, @@ -127,7 +130,10 @@ 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._document.addEventListener( + this._cleanupDocumentTouchmove?.(); + this._cleanupDocumentTouchmove = _bindEventWithOptions( + this._renderer, + this._document, 'touchmove', this._persistentTouchmoveListener, activeCapturingEventOptions, @@ -147,11 +153,7 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { this.stopDragging(drag); if (this._dragInstances.size === 0) { - this._document.removeEventListener( - 'touchmove', - this._persistentTouchmoveListener, - activeCapturingEventOptions, - ); + this._cleanupDocumentTouchmove?.(); } } @@ -174,47 +176,43 @@ 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 = { - handler: (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent), - options: true, - }; + const endEventHandler = (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent); - if (isTouchEvent) { - this._globalListeners.set('touchend', endEventHandler); - this._globalListeners.set('touchcancel', endEventHandler); - } else { - this._globalListeners.set('mouseup', endEventHandler); - } + 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], - 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. - .set('selectstart', { - handler: this._preventDefaultWhileDragging, - options: activeCapturingEventOptions, - }); + ['selectstart', this._preventDefaultWhileDragging, activeCapturingEventOptions], + ]; + + if (isTouchEvent) { + toBind.push( + ['touchend', endEventHandler, capturingEventOptions], + ['touchcancel', endEventHandler, capturingEventOptions], + ); + } else { + toBind.push(['mouseup', endEventHandler, capturingEventOptions]); + } // 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) { - this._globalListeners.set('mousemove', { - handler: (e: Event) => this.pointerMove.next(e as MouseEvent), - options: activeCapturingEventOptions, - }); + toBind.push([ + 'mousemove', + (e: Event) => this.pointerMove.next(e as MouseEvent), + activeCapturingEventOptions, + ]); } this._ngZone.runOutsideAngular(() => { - this._globalListeners.forEach((config, name) => { - this._document.addEventListener(name, config.handler, config.options); - }); + this._globalListeners = toBind.map(([name, handler, options]) => + _bindEventWithOptions(this._renderer, this._document, name, handler, options), + ); }); } } @@ -257,17 +255,20 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { streams.push( new Observable((observer: Observer) => { return this._ngZone.runOutsideAngular(() => { - const eventOptions = true; - const callback = (event: Event) => { - if (this._activeDragInstances().length) { - observer.next(event); - } - }; - - (shadowRoot as ShadowRoot).addEventListener('scroll', callback, eventOptions); + const cleanup = _bindEventWithOptions( + this._renderer, + shadowRoot as ShadowRoot, + 'scroll', + (event: Event) => { + if (this._activeDragInstances().length) { + observer.next(event); + } + }, + capturingEventOptions, + ); return () => { - (shadowRoot as ShadowRoot).removeEventListener('scroll', callback, eventOptions); + cleanup(); }; }); }), @@ -338,10 +339,7 @@ export class DragDropRegistry<_ = unknown, __ = unknown> implements OnDestroy { /** Clears out the global event listeners from the `document`. */ private _clearGlobalListeners() { - this._globalListeners.forEach((config, name) => { - this._document.removeEventListener(name, config.handler, config.options); - }); - - this._globalListeners.clear(); + this._globalListeners?.forEach(cleanup => cleanup()); + this._globalListeners = undefined; } } diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index 0d20f2aa38e6..9bcb2e9d5133 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -9,11 +9,7 @@ import {isFakeMousedownFromScreenReader, isFakeTouchstartFromScreenReader} from '@angular/cdk/a11y'; import {Direction} from '@angular/cdk/bidi'; import {coerceElement} from '@angular/cdk/coercion'; -import { - _getEventTarget, - _getShadowRoot, - normalizePassiveListenerOptions, -} from '@angular/cdk/platform'; +import {_getEventTarget, _getShadowRoot, _bindEventWithOptions} from '@angular/cdk/platform'; import {ViewportRuler} from '@angular/cdk/scrolling'; import { ElementRef, @@ -62,16 +58,16 @@ export interface DragRefConfig { } /** Options that can be used to bind a passive event listener. */ -const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: true}); +const passiveEventListenerOptions = {passive: true}; /** Options that can be used to bind an active event listener. */ -const activeEventListenerOptions = normalizePassiveListenerOptions({passive: false}); +const activeEventListenerOptions = {passive: false}; /** Event options that can be used to bind an active, capturing event. */ -const activeCapturingEventOptions = normalizePassiveListenerOptions({ +const activeCapturingEventOptions = { passive: false, capture: true, -}); +}; /** * Time in milliseconds for which to ignore mouse events, after @@ -121,6 +117,9 @@ 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; @@ -454,15 +453,30 @@ export class DragRef { const element = coerceElement(rootElement); if (element !== this._rootElement) { - 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._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, + ), + ]); this._initialTransform = undefined; this._rootElement = element; } @@ -496,7 +510,7 @@ export class DragRef { /** Removes the dragging functionality from the DOM element. */ dispose() { - this._removeRootElementListeners(this._rootElement); + this._removeRootElementListeners(); // Do this check before removing from the registry since it'll // stop being considered as dragged once it is removed. @@ -626,11 +640,8 @@ export class DragRef { this._pointerMoveSubscription.unsubscribe(); this._pointerUpSubscription.unsubscribe(); this._scrollSubscription.unsubscribe(); - this._getShadowRoot()?.removeEventListener( - 'selectstart', - shadowDomSelectStart, - activeCapturingEventOptions, - ); + this._cleanupShadowRootSelectStart?.(); + this._cleanupShadowRootSelectStart = undefined; } /** Destroys the preview element and its ViewRef. */ @@ -818,7 +829,9 @@ 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(() => { - shadowRoot.addEventListener( + this._cleanupShadowRootSelectStart = _bindEventWithOptions( + this._renderer, + shadowRoot, 'selectstart', shadowDomSelectStart, activeCapturingEventOptions, @@ -1290,10 +1303,9 @@ export class DragRef { } /** Removes the manually-added event listeners from the root element. */ - private _removeRootElementListeners(element: HTMLElement) { - element.removeEventListener('mousedown', this._pointerDown, activeEventListenerOptions); - element.removeEventListener('touchstart', this._pointerDown, passiveEventListenerOptions); - element.removeEventListener('dragstart', this._nativeDragStart, activeEventListenerOptions); + private _removeRootElementListeners() { + this._rootElementCleanups?.forEach(cleanup => cleanup()); + this._rootElementCleanups = 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 e97458f96a17..b866be5c91bd 100644 --- a/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts +++ b/src/cdk/overlay/dispatchers/overlay-outside-click-dispatcher.spec.ts @@ -138,10 +138,18 @@ describe('OverlayOutsideClickDispatcher', () => { spyOn(body, 'removeEventListener'); outsideClickDispatcher.add(overlayRef); - expect(body.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), true); + expect(body.addEventListener).toHaveBeenCalledWith( + 'click', + jasmine.any(Function), + jasmine.objectContaining({capture: true}), + ); overlayRef.dispose(); - expect(body.removeEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), true); + expect(body.removeEventListener).toHaveBeenCalledWith( + 'click', + jasmine.any(Function), + jasmine.objectContaining({capture: 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 5fe85850620a..daef17c474de 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, inject} from '@angular/core'; -import {Platform, _getEventTarget} from '@angular/cdk/platform'; +import {Injectable, NgZone, RendererFactory2, inject} from '@angular/core'; +import {Platform, _bindEventWithOptions, _getEventTarget} from '@angular/cdk/platform'; import {BaseOverlayDispatcher} from './base-overlay-dispatcher'; import type {OverlayRef} from '../overlay-ref'; @@ -19,11 +19,13 @@ import type {OverlayRef} from '../overlay-ref'; @Injectable({providedIn: 'root'}) export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { private _platform = inject(Platform); - private _ngZone = inject(NgZone, {optional: true}); + private _ngZone = inject(NgZone); + private _renderer = inject(RendererFactory2).createRenderer(null, null); 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 { @@ -37,13 +39,26 @@ 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; - - /** @breaking-change 14.0.0 _ngZone will be required. */ - if (this._ngZone) { - this._ngZone.runOutsideAngular(() => this._addEventListeners(body)); - } else { - this._addEventListeners(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, + ), + ]); // click event is not fired on iOS. To make element "clickable" we are // setting the cursor to pointer @@ -60,26 +75,16 @@ export class OverlayOutsideClickDispatcher extends BaseOverlayDispatcher { /** Detaches the global keyboard event listener. */ protected detach() { if (this._isAttached) { - 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); + this._cleanups?.forEach(cleanup => cleanup()); + this._cleanups = undefined; if (this._platform.IOS && this._cursorStyleIsSet) { - body.style.cursor = this._cursorOriginalValue; + this._document.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 9586a2b9badf..23b7ea72883c 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, normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {Platform, _bindEventWithOptions} from '@angular/cdk/platform'; import { Directive, ElementRef, @@ -17,6 +17,7 @@ import { OnDestroy, OnInit, Output, + RendererFactory2, } from '@angular/core'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; import {coerceElement} from '@angular/cdk/coercion'; @@ -38,7 +39,7 @@ type MonitoredElementInfo = { }; /** Options to pass to the animationstart listener. */ -const listenerOptions = normalizePassiveListenerOptions({passive: true}); +const listenerOptions = {passive: true}; /** * An injectable service that can be used to monitor the autofill state of an input. @@ -49,6 +50,7 @@ const listenerOptions = normalizePassiveListenerOptions({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(); @@ -84,9 +86,9 @@ export class AutofillMonitor implements OnDestroy { return info.subject; } - const result = new Subject(); + const subject = 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. @@ -95,29 +97,31 @@ export class AutofillMonitor implements OnDestroy { !element.classList.contains(cssClass) ) { element.classList.add(cssClass); - this._ngZone.run(() => result.next({target: event.target as Element, isAutofilled: true})); + this._ngZone.run(() => subject.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(() => result.next({target: event.target as Element, isAutofilled: false})); + this._ngZone.run(() => + subject.next({target: event.target as Element, isAutofilled: false}), + ); } - }) as EventListenerOrEventListenerObject; + }; - this._ngZone.runOutsideAngular(() => { - element.addEventListener('animationstart', listener, listenerOptions); + const unlisten = this._ngZone.runOutsideAngular(() => { element.classList.add('cdk-text-field-autofill-monitored'); + return _bindEventWithOptions( + this._renderer, + element, + 'animationstart', + listener, + listenerOptions, + ); }); - this._monitoredElements.set(element, { - subject: result, - unlisten: () => { - element.removeEventListener('animationstart', listener, listenerOptions); - }, - }); - - return result; + this._monitoredElements.set(element, {subject, unlisten}); + return subject; } /** diff --git a/src/material/core/private/ripple-loader.ts b/src/material/core/private/ripple-loader.ts index c804b2e857d6..0b7c1d5ae4d4 100644 --- a/src/material/core/private/ripple-loader.ts +++ b/src/material/core/private/ripple-loader.ts @@ -13,6 +13,7 @@ import { Injector, NgZone, OnDestroy, + RendererFactory2, inject, } from '@angular/core'; import { @@ -21,7 +22,7 @@ import { RippleTarget, defaultRippleAnimationConfig, } from '../ripple'; -import {Platform, _getEventTarget} from '@angular/cdk/platform'; +import {Platform, _bindEventWithOptions, _getEventTarget} from '@angular/cdk/platform'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; /** The options for the MatRippleLoader's event listeners. */ @@ -56,22 +57,31 @@ const matRippleDisabled = 'mat-ripple-loader-disabled'; */ @Injectable({providedIn: 'root'}) export class MatRippleLoader implements OnDestroy { - private _document = inject(DOCUMENT, {optional: true}); + private _document = inject(DOCUMENT); 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() { - this._ngZone.runOutsideAngular(() => { - for (const event of rippleInteractionEvents) { - this._document?.addEventListener(event, this._onInteraction, eventListenerOptions); - } + const renderer = inject(RendererFactory2).createRenderer(null, null); + + this._eventCleanups = this._ngZone.runOutsideAngular(() => { + return rippleInteractionEvents.map(name => + _bindEventWithOptions( + renderer, + this._document, + name, + this._onInteraction, + eventListenerOptions, + ), + ); }); } @@ -82,9 +92,7 @@ export class MatRippleLoader implements OnDestroy { this.destroyRipple(host); } - for (const event of rippleInteractionEvents) { - this._document?.removeEventListener(event, this._onInteraction, eventListenerOptions); - } + this._eventCleanups.forEach(cleanup => cleanup()); } /** diff --git a/src/material/datepicker/calendar-body.ts b/src/material/datepicker/calendar-body.ts index 7de7a8125adb..a0620f72829f 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, normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {Platform, _bindEventWithOptions} from '@angular/cdk/platform'; import { ChangeDetectionStrategy, Component, @@ -23,6 +23,7 @@ import { inject, afterNextRender, Injector, + Renderer2, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {NgClass} from '@angular/common'; @@ -66,19 +67,19 @@ export interface MatCalendarUserEvent { } /** Event options that can be used to bind an active, capturing event. */ -const activeCapturingEventOptions = normalizePassiveListenerOptions({ +const activeCapturingEventOptions = { passive: false, capture: true, -}); +}; /** Event options that can be used to bind a passive, capturing event. */ -const passiveCapturingEventOptions = normalizePassiveListenerOptions({ +const passiveCapturingEventOptions = { passive: true, capture: true, -}); +}; /** Event options that can be used to bind a passive, non-capturing event. */ -const passiveEventOptions = normalizePassiveListenerOptions({passive: true}); +const passiveEventOptions = {passive: true}; /** * An internal component used to display calendar data in a table. @@ -101,6 +102,7 @@ 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. @@ -224,6 +226,7 @@ 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-'); @@ -234,22 +237,67 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterView this._ngZone.runOutsideAngular(() => { const element = this._elementRef.nativeElement; - - // `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); + 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, + ), + ]; if (this._platform.isBrowser) { - window.addEventListener('mouseup', this._mouseupHandler); - window.addEventListener('touchend', this._touchendHandler); + cleanups.push( + renderer.listen('window', 'mouseup', this._mouseupHandler), + renderer.listen('window', 'touchend', this._touchendHandler), + ); } + + this._eventCleanups = cleanups; }); } @@ -295,22 +343,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterView } ngOnDestroy() { - 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); - } + this._eventCleanups.forEach(cleanup => cleanup()); } /** Returns whether a cell is active. */ diff --git a/src/material/menu/menu-trigger.ts b/src/material/menu/menu-trigger.ts index 338253c89667..a9c2ce84171d 100644 --- a/src/material/menu/menu-trigger.ts +++ b/src/material/menu/menu-trigger.ts @@ -36,9 +36,10 @@ import { NgZone, OnDestroy, Output, + Renderer2, ViewContainerRef, } from '@angular/core'; -import {normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {_bindEventWithOptions} 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'; @@ -72,7 +73,7 @@ export const MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER = { }; /** Options for binding a passive event listener. */ -const passiveEventListenerOptions = normalizePassiveListenerOptions({passive: true}); +const passiveEventListenerOptions = {passive: true}; /** * Default top padding of the menu panel. @@ -108,6 +109,7 @@ 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; @@ -129,16 +131,6 @@ 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; @@ -223,12 +215,18 @@ 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._element.nativeElement.addEventListener( + this._cleanupTouchstart = _bindEventWithOptions( + renderer, + this._element.nativeElement, 'touchstart', - this._handleTouchStart, + (event: TouchEvent) => { + if (!isFakeTouchstartFromScreenReader(event)) { + this._openedBy = 'touch'; + } + }, passiveEventListenerOptions, ); } @@ -242,12 +240,7 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy { PANELS_TO_TRIGGERS.delete(this.menu); } - this._element.nativeElement.removeEventListener( - 'touchstart', - this._handleTouchStart, - passiveEventListenerOptions, - ); - + this._cleanupTouchstart(); this._pendingRemoval?.unsubscribe(); this._menuCloseSubscription.unsubscribe(); this._closingActionsSubscription.unsubscribe();