From ad70e87dae2c3ad0d337cc19ddcfd4afad2bee48 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 18 Dec 2024 17:18:23 +0200 Subject: [PATCH] refactor(multiple): replace fromEvent usages (#30201) Replaces all of our usages of the rxjs `fromEvent` with direct event listeners going through the renderer. This has a few benefits: * In most cases it made our simpler and easier to follow. * By going through the renderer, other tooling can hook into it (e.g. the tracing service). * It reduces our reliance on rxjs. I also ended up cleaning up the fragile testing setup in `cdk/menu` which would've broken any time we introduce a new `inject` call. --- .../column-resize/column-resize.ts | 42 ++--- .../column-resize/overlay-handle.ts | 36 ++++- src/cdk/listbox/listbox.ts | 34 ++-- src/cdk/menu/menu-aim.ts | 35 +++-- src/cdk/menu/menu-base.ts | 9 +- src/cdk/menu/menu-item-checkbox.spec.ts | 26 +-- src/cdk/menu/menu-item-radio.spec.ts | 30 +--- src/cdk/menu/menu-item.spec.ts | 148 ++++++++---------- src/cdk/menu/menu-item.ts | 32 ++-- src/cdk/menu/menu-trigger.ts | 45 +++--- src/cdk/menu/pointer-focus-tracker.spec.ts | 14 +- src/cdk/menu/pointer-focus-tracker.ts | 83 ++++------ ...exible-connected-position-strategy.spec.ts | 6 + src/cdk/scrolling/scroll-dispatcher.spec.ts | 54 +++---- src/cdk/scrolling/scroll-dispatcher.ts | 50 ++---- src/cdk/scrolling/scrollable.ts | 25 +-- .../scrolling/virtual-scrollable-window.ts | 10 +- src/cdk/text-field/autosize.ts | 25 +-- src/material/tabs/paginated-tab-header.ts | 63 ++++---- tools/public_api_guard/cdk/menu.md | 7 +- tools/public_api_guard/cdk/scrolling.md | 8 +- 21 files changed, 362 insertions(+), 420 deletions(-) diff --git a/src/cdk-experimental/column-resize/column-resize.ts b/src/cdk-experimental/column-resize/column-resize.ts index 5837a4da7fbb..af249ac5e56f 100644 --- a/src/cdk-experimental/column-resize/column-resize.ts +++ b/src/cdk-experimental/column-resize/column-resize.ts @@ -15,10 +15,11 @@ import { Input, NgZone, OnDestroy, + Renderer2, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {fromEvent, merge, Subject} from 'rxjs'; -import {filter, map, mapTo, pairwise, startWith, take, takeUntil} from 'rxjs/operators'; +import {merge, Subject} from 'rxjs'; +import {mapTo, pairwise, startWith, take, takeUntil} from 'rxjs/operators'; import {_closest} from '@angular/cdk-experimental/popover-edit'; @@ -44,6 +45,8 @@ export const COLUMN_RESIZE_OPTIONS = new InjectionToken( */ @Directive() export abstract class ColumnResize implements AfterViewInit, OnDestroy { + private _renderer = inject(Renderer2); + private _eventCleanups: (() => void)[] | undefined; protected readonly destroyed = new Subject(); /* Publicly accessible interface for triggering and being notified of resizes. */ @@ -78,6 +81,7 @@ export abstract class ColumnResize implements AfterViewInit, OnDestroy { } ngOnDestroy() { + this._eventCleanups?.forEach(cleanup => cleanup()); this.destroyed.next(); this.destroyed.complete(); } @@ -99,25 +103,21 @@ export abstract class ColumnResize implements AfterViewInit, OnDestroy { private _listenForRowHoverEvents() { this.ngZone.runOutsideAngular(() => { - const element = this.elementRef.nativeElement!; - - fromEvent(element, 'mouseover') - .pipe( - map(event => _closest(event.target, HEADER_CELL_SELECTOR)), - takeUntil(this.destroyed), - ) - .subscribe(this.eventDispatcher.headerCellHovered); - fromEvent(element, 'mouseleave') - .pipe( - filter( - event => - !!event.relatedTarget && - !(event.relatedTarget as Element).matches(RESIZE_OVERLAY_SELECTOR), - ), - mapTo(null), - takeUntil(this.destroyed), - ) - .subscribe(this.eventDispatcher.headerCellHovered); + const element = this.elementRef.nativeElement; + + this._eventCleanups = [ + this._renderer.listen(element, 'mouseover', (event: MouseEvent) => { + this.eventDispatcher.headerCellHovered.next(_closest(event.target, HEADER_CELL_SELECTOR)); + }), + this._renderer.listen(element, 'mouseleave', (event: MouseEvent) => { + if ( + event.relatedTarget && + !(event.relatedTarget as Element).matches(RESIZE_OVERLAY_SELECTOR) + ) { + this.eventDispatcher.headerCellHovered.next(null); + } + }), + ]; }); } diff --git a/src/cdk-experimental/column-resize/overlay-handle.ts b/src/cdk-experimental/column-resize/overlay-handle.ts index 991d6e2172e6..5eb57991153d 100644 --- a/src/cdk-experimental/column-resize/overlay-handle.ts +++ b/src/cdk-experimental/column-resize/overlay-handle.ts @@ -6,12 +6,20 @@ * found in the LICENSE file at https://angular.dev/license */ -import {AfterViewInit, Directive, ElementRef, OnDestroy, NgZone} from '@angular/core'; +import { + AfterViewInit, + Directive, + ElementRef, + OnDestroy, + NgZone, + Renderer2, + inject, +} from '@angular/core'; import {coerceCssPixelValue} from '@angular/cdk/coercion'; import {Directionality} from '@angular/cdk/bidi'; import {ESCAPE} from '@angular/cdk/keycodes'; import {CdkColumnDef, _CoalescedStyleScheduler} from '@angular/cdk/table'; -import {fromEvent, Subject, merge} from 'rxjs'; +import {Subject, merge, Observable} from 'rxjs'; import { distinctUntilChanged, filter, @@ -37,6 +45,7 @@ import {ResizeRef} from './resize-ref'; */ @Directive() export abstract class ResizeOverlayHandle implements AfterViewInit, OnDestroy { + private _renderer = inject(Renderer2); protected readonly destroyed = new Subject(); protected abstract readonly columnDef: CdkColumnDef; @@ -62,11 +71,11 @@ export abstract class ResizeOverlayHandle implements AfterViewInit, OnDestroy { private _listenForMouseEvents() { this.ngZone.runOutsideAngular(() => { - fromEvent(this.elementRef.nativeElement!, 'mouseenter') + this._observableFromEvent(this.elementRef.nativeElement!, 'mouseenter') .pipe(mapTo(this.resizeRef.origin.nativeElement!), takeUntil(this.destroyed)) .subscribe(cell => this.eventDispatcher.headerCellHovered.next(cell)); - fromEvent(this.elementRef.nativeElement!, 'mouseleave') + this._observableFromEvent(this.elementRef.nativeElement!, 'mouseleave') .pipe( map( event => @@ -76,7 +85,7 @@ export abstract class ResizeOverlayHandle implements AfterViewInit, OnDestroy { ) .subscribe(cell => this.eventDispatcher.headerCellHovered.next(cell)); - fromEvent(this.elementRef.nativeElement!, 'mousedown') + this._observableFromEvent(this.elementRef.nativeElement!, 'mousedown') .pipe(takeUntil(this.destroyed)) .subscribe(mousedownEvent => { this._dragStarted(mousedownEvent); @@ -90,9 +99,9 @@ export abstract class ResizeOverlayHandle implements AfterViewInit, OnDestroy { return; } - const mouseup = fromEvent(this.document, 'mouseup'); - const mousemove = fromEvent(this.document, 'mousemove'); - const escape = fromEvent(this.document, 'keyup').pipe( + const mouseup = this._observableFromEvent(this.document, 'mouseup'); + const mousemove = this._observableFromEvent(this.document, 'mousemove'); + const escape = this._observableFromEvent(this.document, 'keyup').pipe( filter(event => event.keyCode === ESCAPE), ); @@ -233,4 +242,15 @@ export abstract class ResizeOverlayHandle implements AfterViewInit, OnDestroy { } }); } + + private _observableFromEvent(element: Element | Document, name: string) { + return new Observable(subscriber => { + const handler = (event: T) => subscriber.next(event); + const cleanup = this._renderer.listen(element, name, handler); + return () => { + cleanup(); + subscriber.complete(); + }; + }); + } } diff --git a/src/cdk/listbox/listbox.ts b/src/cdk/listbox/listbox.ts index 6ab6a87595ec..0c1b86e0db02 100644 --- a/src/cdk/listbox/listbox.ts +++ b/src/cdk/listbox/listbox.ts @@ -42,10 +42,11 @@ import { OnDestroy, Output, QueryList, + Renderer2, signal, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; -import {defer, fromEvent, merge, Observable, Subject} from 'rxjs'; +import {defer, merge, Observable, Subject} from 'rxjs'; import {filter, map, startWith, switchMap, takeUntil} from 'rxjs/operators'; /** @@ -256,6 +257,8 @@ export class CdkOption implements ListKeyManagerOption, Highlightab ], }) export class CdkListbox implements AfterContentInit, OnDestroy, ControlValueAccessor { + private _cleanupWindowBlur: (() => void) | undefined; + /** The id of the option's host element. */ @Input() get id() { @@ -439,7 +442,16 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con constructor() { if (this._isBrowser) { - this._setPreviousActiveOptionAsActiveOptionOnWindowBlur(); + const renderer = inject(Renderer2); + + this._cleanupWindowBlur = this.ngZone.runOutsideAngular(() => { + return renderer.listen('window', 'blur', () => { + if (this.element.contains(document.activeElement) && this._previousActiveOption) { + this._setActiveOption(this._previousActiveOption); + this._previousActiveOption = null; + } + }); + }); } } @@ -465,6 +477,7 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con } ngOnDestroy() { + this._cleanupWindowBlur?.(); this.listKeyManager?.destroy(); this.destroyed.next(); this.destroyed.complete(); @@ -1035,23 +1048,6 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con const index = this.options.toArray().indexOf(this._lastTriggered!); return index === -1 ? null : index; } - - /** - * Set previous active option as active option on window blur. - * This ensures that the `activeOption` matches the actual focused element when the user returns to the document. - */ - private _setPreviousActiveOptionAsActiveOptionOnWindowBlur() { - this.ngZone.runOutsideAngular(() => { - fromEvent(window, 'blur') - .pipe(takeUntil(this.destroyed)) - .subscribe(() => { - if (this.element.contains(document.activeElement) && this._previousActiveOption) { - this._setActiveOption(this._previousActiveOption); - this._previousActiveOption = null; - } - }); - }); - } } /** Change event that is fired whenever the value of the listbox changes. */ diff --git a/src/cdk/menu/menu-aim.ts b/src/cdk/menu/menu-aim.ts index 1444b218cc15..981f33f16759 100644 --- a/src/cdk/menu/menu-aim.ts +++ b/src/cdk/menu/menu-aim.ts @@ -6,9 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Directive, inject, Injectable, InjectionToken, NgZone, OnDestroy} from '@angular/core'; -import {fromEvent, Subject} from 'rxjs'; -import {filter, takeUntil} from 'rxjs/operators'; +import { + Directive, + inject, + Injectable, + InjectionToken, + NgZone, + OnDestroy, + RendererFactory2, +} from '@angular/core'; +import {Subject} from 'rxjs'; import {FocusableElement, PointerFocusTracker} from './pointer-focus-tracker'; import {Menu} from './menu-interface'; import {throwMissingMenuReference, throwMissingPointerFocusTracker} from './menu-errors'; @@ -102,8 +109,9 @@ function isWithinSubmenu(submenuPoints: DOMRect, m: number, b: number) { */ @Injectable() export class TargetMenuAim implements MenuAim, OnDestroy { - /** The Angular zone. */ private readonly _ngZone = inject(NgZone); + private readonly _renderer = inject(RendererFactory2).createRenderer(null, null); + private _cleanupMousemove: (() => void) | undefined; /** The last NUM_POINTS mouse move events. */ private readonly _points: Point[] = []; @@ -121,6 +129,7 @@ export class TargetMenuAim implements MenuAim, OnDestroy { private readonly _destroyed: Subject = new Subject(); ngOnDestroy() { + this._cleanupMousemove?.(); this._destroyed.next(); this._destroyed.complete(); } @@ -231,18 +240,20 @@ export class TargetMenuAim implements MenuAim, OnDestroy { /** Subscribe to the root menus mouse move events and update the tracked mouse points. */ private _subscribeToMouseMoves() { - this._ngZone.runOutsideAngular(() => { - fromEvent(this._menu.nativeElement, 'mousemove') - .pipe( - filter((_: MouseEvent, index: number) => index % MOUSE_MOVE_SAMPLE_FREQUENCY === 0), - takeUntil(this._destroyed), - ) - .subscribe((event: MouseEvent) => { + this._cleanupMousemove?.(); + + this._cleanupMousemove = this._ngZone.runOutsideAngular(() => { + let eventIndex = 0; + + return this._renderer.listen(this._menu.nativeElement, 'mousemove', (event: MouseEvent) => { + if (eventIndex % MOUSE_MOVE_SAMPLE_FREQUENCY === 0) { this._points.push({x: event.clientX, y: event.clientY}); if (this._points.length > NUM_POINTS) { this._points.shift(); } - }); + } + eventIndex++; + }); }); } } diff --git a/src/cdk/menu/menu-base.ts b/src/cdk/menu/menu-base.ts index 93be3a9819e1..36a4a357b181 100644 --- a/src/cdk/menu/menu-base.ts +++ b/src/cdk/menu/menu-base.ts @@ -17,6 +17,7 @@ import { NgZone, OnDestroy, QueryList, + Renderer2, computed, inject, signal, @@ -51,12 +52,12 @@ export abstract class CdkMenuBase extends CdkMenuGroup implements Menu, AfterContentInit, OnDestroy { + protected ngZone = inject(NgZone); + private _renderer = inject(Renderer2); + /** The menu's native DOM host element. */ readonly nativeElement: HTMLElement = inject(ElementRef).nativeElement; - /** The Angular zone. */ - protected ngZone = inject(NgZone); - /** The stack of menus this menu belongs to. */ readonly menuStack: MenuStack = inject(MENU_STACK); @@ -225,7 +226,7 @@ export abstract class CdkMenuBase private _setUpPointerTracker() { if (this.menuAim) { this.ngZone.runOutsideAngular(() => { - this.pointerTracker = new PointerFocusTracker(this.items); + this.pointerTracker = new PointerFocusTracker(this._renderer, this.items); }); this.menuAim.initialize(this, this.pointerTracker!); } diff --git a/src/cdk/menu/menu-item-checkbox.spec.ts b/src/cdk/menu/menu-item-checkbox.spec.ts index cadbb23762b5..d811b89f0178 100644 --- a/src/cdk/menu/menu-item-checkbox.spec.ts +++ b/src/cdk/menu/menu-item-checkbox.spec.ts @@ -1,30 +1,16 @@ -import {Component, ElementRef} from '@angular/core'; -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItemCheckbox} from './menu-item-checkbox'; -import {CDK_MENU} from './menu-interface'; -import {CdkMenu} from './menu'; -import {MENU_STACK, MenuStack} from './menu-stack'; describe('MenuItemCheckbox', () => { let fixture: ComponentFixture; let checkbox: CdkMenuItemCheckbox; let checkboxElement: HTMLButtonElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule, SingleCheckboxButton], - providers: [ - {provide: CDK_MENU, useClass: CdkMenu}, - {provide: MENU_STACK, useClass: MenuStack}, - // View engine can't figure out the ElementRef to inject so we need to provide a fake - {provide: ElementRef, useValue: new ElementRef(null)}, - ], - }); - })); - beforeEach(() => { + TestBed.configureTestingModule({imports: [CdkMenuModule, SingleCheckboxButton]}); fixture = TestBed.createComponent(SingleCheckboxButton); fixture.detectChanges(); @@ -99,7 +85,11 @@ describe('MenuItemCheckbox', () => { }); @Component({ - template: ``, + template: ` +
+ +
+ `, imports: [CdkMenuModule], }) class SingleCheckboxButton {} diff --git a/src/cdk/menu/menu-item-radio.spec.ts b/src/cdk/menu/menu-item-radio.spec.ts index 5a7a77c59711..47b685ebd370 100644 --- a/src/cdk/menu/menu-item-radio.spec.ts +++ b/src/cdk/menu/menu-item-radio.spec.ts @@ -1,34 +1,16 @@ -import {Component, ElementRef} from '@angular/core'; -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; -import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItemRadio} from './menu-item-radio'; -import {CDK_MENU} from './menu-interface'; -import {CdkMenu} from './menu'; -import {MENU_STACK, MenuStack} from './menu-stack'; describe('MenuItemRadio', () => { let fixture: ComponentFixture; let radioButton: CdkMenuItemRadio; let radioElement: HTMLButtonElement; - let selectionDispatcher: UniqueSelectionDispatcher; - - beforeEach(waitForAsync(() => { - selectionDispatcher = new UniqueSelectionDispatcher(); - TestBed.configureTestingModule({ - imports: [CdkMenuModule, SimpleRadioButton], - providers: [ - {provide: UniqueSelectionDispatcher, useValue: selectionDispatcher}, - {provide: CDK_MENU, useClass: CdkMenu}, - {provide: MENU_STACK, useClass: MenuStack}, - // View engine can't figure out the ElementRef to inject so we need to provide a fake - {provide: ElementRef, useValue: new ElementRef(null)}, - ], - }); - })); beforeEach(() => { + TestBed.configureTestingModule({imports: [CdkMenuModule, SimpleRadioButton]}); fixture = TestBed.createComponent(SimpleRadioButton); fixture.detectChanges(); @@ -93,7 +75,11 @@ describe('MenuItemRadio', () => { }); @Component({ - template: ``, + template: ` +
+ +
+ `, imports: [CdkMenuModule], }) class SimpleRadioButton {} diff --git a/src/cdk/menu/menu-item.spec.ts b/src/cdk/menu/menu-item.spec.ts index 0d20cdab6fb0..83f19a7fb016 100644 --- a/src/cdk/menu/menu-item.spec.ts +++ b/src/cdk/menu/menu-item.spec.ts @@ -1,13 +1,10 @@ -import {Component, Type, ElementRef} from '@angular/core'; -import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; +import {Component, Type} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; import {dispatchKeyboardEvent} from '@angular/cdk/testing/private'; import {By} from '@angular/platform-browser'; import {ENTER} from '@angular/cdk/keycodes'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItem} from './menu-item'; -import {CDK_MENU} from './menu-interface'; -import {CdkMenu} from './menu'; -import {MENU_STACK, MenuStack} from './menu-stack'; describe('MenuItem', () => { describe('with no complex inner elements', () => { @@ -15,19 +12,6 @@ describe('MenuItem', () => { let menuItem: CdkMenuItem; let nativeButton: HTMLButtonElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [SingleMenuItem], - providers: [ - {provide: CDK_MENU, useClass: CdkMenu}, - {provide: MENU_STACK, useClass: MenuStack}, - // View engine can't figure out the ElementRef to inject so we need to provide a fake - {provide: ElementRef, useValue: new ElementRef(null)}, - ], - }); - })); - beforeEach(() => { fixture = TestBed.createComponent(SingleMenuItem); fixture.detectChanges(); @@ -79,20 +63,7 @@ describe('MenuItem', () => { * @param componentClass the component to create */ function createComponent(componentClass: Type) { - let fixture: ComponentFixture; - - TestBed.configureTestingModule({ - imports: [CdkMenuModule, MatIcon], - providers: [ - {provide: CDK_MENU, useClass: CdkMenu}, - {provide: MENU_STACK, useClass: MenuStack}, - // View engine can't figure out the ElementRef to inject so we need to provide a fake - {provide: ElementRef, useValue: new ElementRef(null)}, - ], - declarations: [componentClass], - }); - - fixture = TestBed.createComponent(componentClass); + const fixture = TestBed.createComponent(componentClass); fixture.detectChanges(); menuItem = fixture.debugElement.query(By.directive(CdkMenuItem)).injector.get(CdkMenuItem); @@ -113,53 +84,57 @@ describe('MenuItem', () => { expect(menuItem.getLabel()).toEqual('Click me!'); }); - it( - 'should get the text for menu item with single nested component with the material ' + - 'icon class', - () => { - const fixture = createComponent(MenuItemWithIconClass); - expect(menuItem.getLabel()).toEqual('unicorn Click me!'); - fixture.componentInstance.typeahead = 'Click me!'; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - expect(menuItem.getLabel()).toEqual('Click me!'); - }, - ); + it('should get the text for menu item with single nested component with the material icon class', () => { + const fixture = createComponent(MenuItemWithIconClass); + expect(menuItem.getLabel()).toEqual('unicorn Click me!'); + fixture.componentInstance.typeahead = 'Click me!'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(menuItem.getLabel()).toEqual('Click me!'); + }); it('should get the text for a menu item with bold marked text', () => { createComponent(MenuItemWithBoldElement); expect(menuItem.getLabel()).toEqual('Click me!'); }); - it( - 'should get the text for a menu item with nested icon, nested icon class and nested ' + - 'wrapping elements', - () => { - const fixture = createComponent(MenuItemWithMultipleNestings); - expect(menuItem.getLabel()).toEqual('unicorn Click menume!'); - fixture.componentInstance.typeahead = 'Click me!'; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - expect(menuItem.getLabel()).toEqual('Click me!'); - }, - ); + it('should get the text for a menu item with nested icon, nested icon class and nested wrapping elements', () => { + const fixture = createComponent(MenuItemWithMultipleNestings); + expect(menuItem.getLabel()).toEqual('unicorn Click menume!'); + fixture.componentInstance.typeahead = 'Click me!'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(menuItem.getLabel()).toEqual('Click me!'); + }); }); }); @Component({ - template: ``, - standalone: false, + selector: 'mat-icon', + template: '', +}) +class FakeMatIcon {} + +@Component({ + template: ` +
+ +
+ `, + imports: [CdkMenuModule], }) class SingleMenuItem {} @Component({ template: ` - +
+ +
`, - standalone: false, + imports: [CdkMenuModule, FakeMatIcon], }) class MenuItemWithIcon { typeahead: string; @@ -167,45 +142,46 @@ class MenuItemWithIcon { @Component({ template: ` - +
+ +
`, - standalone: false, + imports: [CdkMenuModule], }) class MenuItemWithIconClass { typeahead: string; } @Component({ - template: ` `, - standalone: false, + template: ` +
+ +
+ `, + imports: [CdkMenuModule], }) class MenuItemWithBoldElement {} @Component({ template: ` - + + `, - standalone: false, + imports: [CdkMenuModule, FakeMatIcon], }) class MenuItemWithMultipleNestings { typeahead: string; } - -@Component({ - selector: 'mat-icon', - template: '', - imports: [CdkMenuModule], -}) -class MatIcon {} diff --git a/src/cdk/menu/menu-item.ts b/src/cdk/menu/menu-item.ts index f227092f7d77..c203c2fc5633 100644 --- a/src/cdk/menu/menu-item.ts +++ b/src/cdk/menu/menu-item.ts @@ -16,12 +16,12 @@ import { NgZone, OnDestroy, Output, + Renderer2, } from '@angular/core'; import {FocusableOption, InputModalityDetector} from '@angular/cdk/a11y'; import {ENTER, hasModifierKey, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes'; import {Directionality} from '@angular/cdk/bidi'; -import {fromEvent, Subject} from 'rxjs'; -import {filter, takeUntil} from 'rxjs/operators'; +import {Subject} from 'rxjs'; import {CdkMenuTrigger} from './menu-trigger'; import {CDK_MENU, Menu} from './menu-interface'; import {FocusNext, MENU_STACK} from './menu-stack'; @@ -53,6 +53,8 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler, readonly _elementRef: ElementRef = inject(ElementRef); protected _ngZone = inject(NgZone); private readonly _inputModalityDetector = inject(InputModalityDetector); + private readonly _renderer = inject(Renderer2); + private _cleanupMouseEnter: (() => void) | undefined; /** The menu aim service used by this menu. */ private readonly _menuAim = inject(MENU_AIM, {optional: true}); @@ -108,6 +110,7 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler, } ngOnDestroy() { + this._cleanupMouseEnter?.(); this.destroyed.next(); this.destroyed.complete(); } @@ -266,26 +269,21 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler, const closeOpenSiblings = () => this._ngZone.run(() => this._menuStack.closeSubMenuOf(this._parentMenu!)); - this._ngZone.runOutsideAngular(() => - fromEvent(this._elementRef.nativeElement, 'mouseenter') - .pipe( - filter(() => { - return ( - // Skip fake `mouseenter` events dispatched by touch devices. - this._inputModalityDetector.mostRecentModality !== 'touch' && - !this._menuStack.isEmpty() && - !this.hasMenu - ); - }), - takeUntil(this.destroyed), - ) - .subscribe(() => { + this._cleanupMouseEnter = this._ngZone.runOutsideAngular(() => + this._renderer.listen(this._elementRef.nativeElement, 'mouseenter', () => { + // Skip fake `mouseenter` events dispatched by touch devices. + if ( + this._inputModalityDetector.mostRecentModality !== 'touch' && + !this._menuStack.isEmpty() && + !this.hasMenu + ) { if (this._menuAim) { this._menuAim.toggle(closeOpenSiblings); } else { closeOpenSiblings(); } - }), + } + }), ); } } diff --git a/src/cdk/menu/menu-trigger.ts b/src/cdk/menu/menu-trigger.ts index 36025000baf4..6405997e9f13 100644 --- a/src/cdk/menu/menu-trigger.ts +++ b/src/cdk/menu/menu-trigger.ts @@ -6,7 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ChangeDetectorRef, Directive, ElementRef, inject, NgZone, OnDestroy} from '@angular/core'; +import { + ChangeDetectorRef, + Directive, + ElementRef, + inject, + NgZone, + OnDestroy, + Renderer2, +} from '@angular/core'; import {InputModalityDetector} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import { @@ -27,8 +35,7 @@ import { UP_ARROW, } from '@angular/cdk/keycodes'; import {_getEventTarget} from '@angular/cdk/platform'; -import {fromEvent} from 'rxjs'; -import {filter, takeUntil} from 'rxjs/operators'; +import {takeUntil} from 'rxjs/operators'; import {CDK_MENU, Menu} from './menu-interface'; import {PARENT_OR_NEW_MENU_STACK_PROVIDER} from './menu-stack'; import {MENU_AIM} from './menu-aim'; @@ -72,6 +79,8 @@ export class CdkMenuTrigger extends CdkMenuTriggerBase implements OnDestroy { private readonly _changeDetectorRef = inject(ChangeDetectorRef); private readonly _inputModalityDetector = inject(InputModalityDetector); private readonly _directionality = inject(Directionality, {optional: true}); + private readonly _renderer = inject(Renderer2); + private _cleanupMouseenter: () => void; /** The parent menu this trigger belongs to. */ private readonly _parentMenu = inject(CDK_MENU, {optional: true}); @@ -124,6 +133,11 @@ export class CdkMenuTrigger extends CdkMenuTriggerBase implements OnDestroy { return this.childMenu; } + override ngOnDestroy(): void { + this._cleanupMouseenter(); + super.ngOnDestroy(); + } + /** * Handles keyboard events for the menu item. * @param event The keyboard event to handle @@ -196,20 +210,14 @@ export class CdkMenuTrigger extends CdkMenuTriggerBase implements OnDestroy { * into. */ private _subscribeToMouseEnter() { - this._ngZone.runOutsideAngular(() => { - fromEvent(this._elementRef.nativeElement, 'mouseenter') - .pipe( - filter(() => { - return ( - // Skip fake `mouseenter` events dispatched by touch devices. - this._inputModalityDetector.mostRecentModality !== 'touch' && - !this.menuStack.isEmpty() && - !this.isOpen() - ); - }), - takeUntil(this.destroyed), - ) - .subscribe(() => { + this._cleanupMouseenter = this._ngZone.runOutsideAngular(() => { + return this._renderer.listen(this._elementRef.nativeElement, 'mouseenter', () => { + if ( + // Skip fake `mouseenter` events dispatched by touch devices. + this._inputModalityDetector.mostRecentModality !== 'touch' && + !this.menuStack.isEmpty() && + !this.isOpen() + ) { // Closes any sibling menu items and opens the menu associated with this trigger. const toggleMenus = () => this._ngZone.run(() => { @@ -222,7 +230,8 @@ export class CdkMenuTrigger extends CdkMenuTriggerBase implements OnDestroy { } else { toggleMenus(); } - }); + } + }); }); } diff --git a/src/cdk/menu/pointer-focus-tracker.spec.ts b/src/cdk/menu/pointer-focus-tracker.spec.ts index 12797c27cf76..b747512e442a 100644 --- a/src/cdk/menu/pointer-focus-tracker.spec.ts +++ b/src/cdk/menu/pointer-focus-tracker.spec.ts @@ -1,4 +1,12 @@ -import {Component, QueryList, ElementRef, ViewChildren, AfterViewInit, inject} from '@angular/core'; +import { + Component, + QueryList, + ElementRef, + ViewChildren, + AfterViewInit, + inject, + Renderer2, +} from '@angular/core'; import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing'; import {createMouseEvent, dispatchEvent} from '../../cdk/testing/private'; import {Observable} from 'rxjs'; @@ -117,6 +125,8 @@ class MockWrapper implements FocusableElement { imports: [MockWrapper], }) class MultiElementWithConditionalComponent implements AfterViewInit { + private _renderer = inject(Renderer2); + /** Whether the third element should be displayed. */ showThird = false; @@ -127,6 +137,6 @@ class MultiElementWithConditionalComponent implements AfterViewInit { focusTracker: PointerFocusTracker; ngAfterViewInit() { - this.focusTracker = new PointerFocusTracker(this._allItems); + this.focusTracker = new PointerFocusTracker(this._renderer, this._allItems); } } diff --git a/src/cdk/menu/pointer-focus-tracker.ts b/src/cdk/menu/pointer-focus-tracker.ts index e54207cd3ae9..7777827d77e5 100644 --- a/src/cdk/menu/pointer-focus-tracker.ts +++ b/src/cdk/menu/pointer-focus-tracker.ts @@ -6,9 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ElementRef, QueryList} from '@angular/core'; -import {defer, fromEvent, Observable, Subject} from 'rxjs'; -import {mapTo, mergeAll, mergeMap, startWith, takeUntil} from 'rxjs/operators'; +import {ElementRef, QueryList, Renderer2} from '@angular/core'; +import {Observable, Subject, Subscription} from 'rxjs'; +import {startWith} from 'rxjs/operators'; /** Item to track for mouse focus events. */ export interface FocusableElement { @@ -21,11 +21,14 @@ export interface FocusableElement { * observables which emit when the users mouse enters and leaves a tracked element. */ export class PointerFocusTracker { + private _eventCleanups: (() => void)[] | undefined; + private _itemsSubscription: Subscription | undefined; + /** Emits when an element is moused into. */ - readonly entered: Observable = this._getItemPointerEntries(); + readonly entered: Observable = new Subject(); /** Emits when an element is moused out. */ - readonly exited: Observable = this._getItemPointerExits(); + readonly exited: Observable = new Subject(); /** The element currently under mouse focus. */ activeElement?: T; @@ -33,13 +36,11 @@ export class PointerFocusTracker { /** The element previously under mouse focus. */ previousElement?: T; - /** Emits when this is destroyed. */ - private readonly _destroyed: Subject = new Subject(); - constructor( - /** The list of items being tracked. */ + private _renderer: Renderer2, private readonly _items: QueryList, ) { + this._bindEvents(); this.entered.subscribe(element => (this.activeElement = element)); this.exited.subscribe(() => { this.previousElement = this.activeElement; @@ -49,49 +50,33 @@ export class PointerFocusTracker { /** Stop the managers listeners. */ destroy() { - this._destroyed.next(); - this._destroyed.complete(); + this._cleanupEvents(); + this._itemsSubscription?.unsubscribe(); } - /** - * Gets a stream of pointer (mouse) entries into the given items. - * This should typically run outside the Angular zone. - */ - private _getItemPointerEntries(): Observable { - return defer(() => - this._items.changes.pipe( - startWith(this._items), - mergeMap((list: QueryList) => - list.map(element => - fromEvent(element._elementRef.nativeElement, 'mouseenter').pipe( - mapTo(element), - takeUntil(this._items.changes), - ), - ), - ), - mergeAll(), - ), - ); + /** Binds the enter/exit events on all the items. */ + private _bindEvents() { + // TODO(crisbeto): this can probably be simplified by binding a single event on a parent node. + this._itemsSubscription = this._items.changes.pipe(startWith(this._items)).subscribe(() => { + this._cleanupEvents(); + this._eventCleanups = []; + this._items.forEach(item => { + const element = item._elementRef.nativeElement; + this._eventCleanups!.push( + this._renderer.listen(element, 'mouseenter', () => { + (this.entered as Subject).next(item); + }), + this._renderer.listen(element, 'mouseout', () => { + (this.exited as Subject).next(item); + }), + ); + }); + }); } - /** - * Gets a stream of pointer (mouse) exits out of the given items. - * This should typically run outside the Angular zone. - */ - private _getItemPointerExits() { - return defer(() => - this._items.changes.pipe( - startWith(this._items), - mergeMap((list: QueryList) => - list.map(element => - fromEvent(element._elementRef.nativeElement, 'mouseout').pipe( - mapTo(element), - takeUntil(this._items.changes), - ), - ), - ), - mergeAll(), - ), - ); + /** Cleans up the currently-bound events. */ + private _cleanupEvents() { + this._eventCleanups?.forEach(cleanup => cleanup()); + this._eventCleanups = undefined; } } diff --git a/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts b/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts index d9f4bf0754ea..ad1b821aacb8 100644 --- a/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts +++ b/src/cdk/overlay/position/flexible-connected-position-strategy.spec.ts @@ -6,6 +6,8 @@ import { Component, ElementRef, Injector, + Renderer2, + RendererFactory2, runInInjectionContext, } from '@angular/core'; import {fakeAsync, TestBed, tick} from '@angular/core/testing'; @@ -2501,6 +2503,10 @@ describe('FlexibleConnectedPositionStrategy', () => { provide: ElementRef, useValue: new ElementRef(scrollable), }, + { + provide: Renderer2, + useValue: TestBed.inject(RendererFactory2).createRenderer(null, null), + }, ], }); diff --git a/src/cdk/scrolling/scroll-dispatcher.spec.ts b/src/cdk/scrolling/scroll-dispatcher.spec.ts index 4c7b59d4259f..530547bccd51 100644 --- a/src/cdk/scrolling/scroll-dispatcher.spec.ts +++ b/src/cdk/scrolling/scroll-dispatcher.spec.ts @@ -195,73 +195,73 @@ describe('ScrollDispatcher', () => { describe('lazy subscription', () => { let scroll: ScrollDispatcher; - beforeEach(inject([ScrollDispatcher], (s: ScrollDispatcher) => { - scroll = s; - })); + function hasGlobalListener(): boolean { + return !!(scroll as any)._cleanupGlobalListener; + } + + beforeEach(() => { + scroll = TestBed.inject(ScrollDispatcher); + }); it('should lazily add global listeners as service subscriptions are added and removed', () => { - expect(scroll._globalSubscription) - .withContext('Expected no global listeners on init.') - .toBeNull(); + expect(hasGlobalListener()).withContext('Expected no global listeners on init.').toBe(false); const subscription = scroll.scrolled(0).subscribe(() => {}); - expect(scroll._globalSubscription).toBeTruthy( - 'Expected global listeners after a subscription has been added.', - ); + expect(hasGlobalListener()) + .withContext('Expected global listeners after a subscription has been added.') + .toBe(true); subscription.unsubscribe(); - expect(scroll._globalSubscription).toBeNull( - 'Expected global listeners to have been removed after the subscription has stopped.', - ); + expect(hasGlobalListener()) + .withContext( + 'Expected global listeners to have been removed after the subscription has stopped.', + ) + .toBe(false); }); it('should remove global listeners on unsubscribe, despite any other live scrollables', () => { const fixture = TestBed.createComponent(NestedScrollingComponent); fixture.detectChanges(); - expect(scroll._globalSubscription) - .withContext('Expected no global listeners on init.') - .toBeNull(); + expect(hasGlobalListener()).withContext('Expected no global listeners on init.').toBe(false); expect(scroll.scrollContainers.size).withContext('Expected multiple scrollables').toBe(4); const subscription = scroll.scrolled(0).subscribe(() => {}); - expect(scroll._globalSubscription) + expect(hasGlobalListener()) .withContext('Expected global listeners after a subscription has been added.') - .toBeTruthy(); + .toBe(true); subscription.unsubscribe(); - expect(scroll._globalSubscription) + expect(hasGlobalListener()) .withContext( 'Expected global listeners to have been removed after ' + 'the subscription has stopped.', ) - .toBeNull(); + .toBe(false); expect(scroll.scrollContainers.size) .withContext('Expected scrollable count to stay the same') .toBe(4); }); it('should remove the global subscription on destroy', () => { - expect(scroll._globalSubscription) - .withContext('Expected no global listeners on init.') - .toBeNull(); + expect(hasGlobalListener()).withContext('Expected no global listeners on init.').toBe(false); const subscription = scroll.scrolled(0).subscribe(() => {}); - expect(scroll._globalSubscription) + expect(hasGlobalListener()) .withContext('Expected global listeners after a subscription has been added.') - .toBeTruthy(); + .toBe(true); scroll.ngOnDestroy(); - expect(scroll._globalSubscription) + expect(hasGlobalListener()) .withContext( - 'Expected global listeners to have been removed after ' + 'the subscription has stopped.', + 'Expected global listeners to have been removed after the subscription has stopped.', ) - .toBeNull(); + .toBe(false); subscription.unsubscribe(); }); diff --git a/src/cdk/scrolling/scroll-dispatcher.ts b/src/cdk/scrolling/scroll-dispatcher.ts index 4cd94c971a5c..eefe30167944 100644 --- a/src/cdk/scrolling/scroll-dispatcher.ts +++ b/src/cdk/scrolling/scroll-dispatcher.ts @@ -8,11 +8,10 @@ import {coerceElement} from '@angular/cdk/coercion'; import {Platform} from '@angular/cdk/platform'; -import {ElementRef, Injectable, NgZone, OnDestroy, inject} from '@angular/core'; -import {fromEvent, of as observableOf, Subject, Subscription, Observable, Observer} from 'rxjs'; +import {ElementRef, Injectable, NgZone, OnDestroy, RendererFactory2, inject} from '@angular/core'; +import {of as observableOf, Subject, Subscription, Observable, Observer} from 'rxjs'; import {auditTime, filter} from 'rxjs/operators'; import type {CdkScrollable} from './scrollable'; -import {DOCUMENT} from '@angular/common'; /** Time in ms to throttle the scrolling events by default. */ export const DEFAULT_SCROLL_TIME = 20; @@ -25,9 +24,8 @@ export const DEFAULT_SCROLL_TIME = 20; export class ScrollDispatcher implements OnDestroy { private _ngZone = inject(NgZone); private _platform = inject(Platform); - - /** Used to reference correct document/window */ - protected _document = inject(DOCUMENT, {optional: true})!; + private _renderer = inject(RendererFactory2).createRenderer(null, null); + private _cleanupGlobalListener: (() => void) | undefined; constructor(...args: unknown[]); constructor() {} @@ -35,9 +33,6 @@ export class ScrollDispatcher implements OnDestroy { /** Subject for notifying that a registered scrollable reference element has been scrolled. */ private readonly _scrolled = new Subject(); - /** Keeps track of the global `scroll` and `resize` subscriptions. */ - _globalSubscription: Subscription | null = null; - /** Keeps track of the amount of subscriptions to `scrolled`. Used for cleaning up afterwards. */ private _scrolledCount = 0; @@ -90,8 +85,10 @@ export class ScrollDispatcher implements OnDestroy { } return new Observable((observer: Observer) => { - if (!this._globalSubscription) { - this._addGlobalListener(); + if (!this._cleanupGlobalListener) { + this._cleanupGlobalListener = this._ngZone.runOutsideAngular(() => + this._renderer.listen('document', 'scroll', () => this._scrolled.next()), + ); } // In the case of a 0ms delay, use an observable without auditTime @@ -108,14 +105,16 @@ export class ScrollDispatcher implements OnDestroy { this._scrolledCount--; if (!this._scrolledCount) { - this._removeGlobalListener(); + this._cleanupGlobalListener?.(); + this._cleanupGlobalListener = undefined; } }; }); } ngOnDestroy() { - this._removeGlobalListener(); + this._cleanupGlobalListener?.(); + this._cleanupGlobalListener = undefined; this.scrollContainers.forEach((_, container) => this.deregister(container)); this._scrolled.complete(); } @@ -133,9 +132,7 @@ export class ScrollDispatcher implements OnDestroy { const ancestors = this.getAncestorScrollContainers(elementOrElementRef); return this.scrolled(auditTimeInMs).pipe( - filter(target => { - return !target || ancestors.indexOf(target) > -1; - }), + filter(target => !target || ancestors.indexOf(target) > -1), ); } @@ -152,11 +149,6 @@ export class ScrollDispatcher implements OnDestroy { return scrollingContainers; } - /** Use defaultView of injected document if available or fallback to global window reference */ - private _getWindow(): Window { - return this._document.defaultView || window; - } - /** Returns true if the element is contained within the provided Scrollable. */ private _scrollableContainsElement( scrollable: CdkScrollable, @@ -175,20 +167,4 @@ export class ScrollDispatcher implements OnDestroy { return false; } - - /** Sets up the global scroll listeners. */ - private _addGlobalListener() { - this._globalSubscription = this._ngZone.runOutsideAngular(() => { - const window = this._getWindow(); - return fromEvent(window.document, 'scroll').subscribe(() => this._scrolled.next()); - }); - } - - /** Cleans up the global scroll listener. */ - private _removeGlobalListener() { - if (this._globalSubscription) { - this._globalSubscription.unsubscribe(); - this._globalSubscription = null; - } - } } diff --git a/src/cdk/scrolling/scrollable.ts b/src/cdk/scrolling/scrollable.ts index fd666fcd7df6..e56ec2658998 100644 --- a/src/cdk/scrolling/scrollable.ts +++ b/src/cdk/scrolling/scrollable.ts @@ -12,9 +12,8 @@ import { RtlScrollAxisType, supportsScrollBehavior, } from '@angular/cdk/platform'; -import {Directive, ElementRef, NgZone, OnDestroy, OnInit, inject} from '@angular/core'; -import {fromEvent, Observable, Subject, Observer} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; +import {Directive, ElementRef, NgZone, OnDestroy, OnInit, Renderer2, inject} from '@angular/core'; +import {Observable, Subject} from 'rxjs'; import {ScrollDispatcher} from './scroll-dispatcher'; export type _Without = {[P in keyof T]?: never}; @@ -49,25 +48,27 @@ export class CdkScrollable implements OnInit, OnDestroy { protected scrollDispatcher = inject(ScrollDispatcher); protected ngZone = inject(NgZone); protected dir? = inject(Directionality, {optional: true}); - + protected _scrollElement: EventTarget = this.elementRef.nativeElement; protected readonly _destroyed = new Subject(); - - protected _elementScrolled: Observable = new Observable((observer: Observer) => - this.ngZone.runOutsideAngular(() => - fromEvent(this.elementRef.nativeElement, 'scroll') - .pipe(takeUntil(this._destroyed)) - .subscribe(observer), - ), - ); + private _renderer = inject(Renderer2); + private _cleanupScroll: (() => void) | undefined; + private _elementScrolled = new Subject(); constructor(...args: unknown[]); constructor() {} ngOnInit() { + this._cleanupScroll = this.ngZone.runOutsideAngular(() => + this._renderer.listen(this._scrollElement, 'scroll', event => + this._elementScrolled.next(event), + ), + ); this.scrollDispatcher.register(this); } ngOnDestroy() { + this._cleanupScroll?.(); + this._elementScrolled.complete(); this.scrollDispatcher.deregister(this); this._destroyed.next(); this._destroyed.complete(); diff --git a/src/cdk/scrolling/virtual-scrollable-window.ts b/src/cdk/scrolling/virtual-scrollable-window.ts index 2fb28fa49923..3b6b805499b6 100644 --- a/src/cdk/scrolling/virtual-scrollable-window.ts +++ b/src/cdk/scrolling/virtual-scrollable-window.ts @@ -7,8 +7,6 @@ */ import {Directive, ElementRef} from '@angular/core'; -import {fromEvent, Observable, Observer} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable'; /** @@ -19,18 +17,12 @@ import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable'; providers: [{provide: VIRTUAL_SCROLLABLE, useExisting: CdkVirtualScrollableWindow}], }) export class CdkVirtualScrollableWindow extends CdkVirtualScrollable { - protected override _elementScrolled: Observable = new Observable( - (observer: Observer) => - this.ngZone.runOutsideAngular(() => - fromEvent(document, 'scroll').pipe(takeUntil(this._destroyed)).subscribe(observer), - ), - ); - constructor(...args: unknown[]); constructor() { super(); this.elementRef = new ElementRef(document.documentElement); + this._scrollElement = document; } override measureBoundingClientRectWithScrollOffset( diff --git a/src/cdk/text-field/autosize.ts b/src/cdk/text-field/autosize.ts index deb7d4ca057e..4fa07298de84 100644 --- a/src/cdk/text-field/autosize.ts +++ b/src/cdk/text-field/autosize.ts @@ -22,8 +22,8 @@ import { import {DOCUMENT} from '@angular/common'; import {Platform} from '@angular/cdk/platform'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; -import {auditTime, takeUntil} from 'rxjs/operators'; -import {fromEvent, Subject} from 'rxjs'; +import {auditTime} from 'rxjs/operators'; +import {Subject} from 'rxjs'; import {_CdkTextFieldStyleLoader} from './text-field-style-loader'; /** Directive to automatically resize a textarea to fit its content. */ @@ -43,6 +43,7 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { private _platform = inject(Platform); private _ngZone = inject(NgZone); private _renderer = inject(Renderer2); + private _resizeEvents = new Subject(); /** Keep track of the previous textarea value to avoid resizing when the value hasn't changed. */ private _previousValue?: string; @@ -159,16 +160,12 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { this.resizeToFitContent(); this._ngZone.runOutsideAngular(() => { - const window = this._getWindow(); - - fromEvent(window, 'resize') - .pipe(auditTime(16), takeUntil(this._destroyed)) - .subscribe(() => this.resizeToFitContent(true)); - this._listenerCleanups = [ + this._renderer.listen('window', 'resize', () => this._resizeEvents.next()), this._renderer.listen(this._textareaElement, 'focus', this._handleFocusEvent), this._renderer.listen(this._textareaElement, 'blur', this._handleFocusEvent), ]; + this._resizeEvents.pipe(auditTime(16)).subscribe(() => this.resizeToFitContent(true)); }); this._isViewInited = true; @@ -178,6 +175,7 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { ngOnDestroy() { this._listenerCleanups?.forEach(cleanup => cleanup()); + this._resizeEvents.complete(); this._destroyed.next(); this._destroyed.complete(); } @@ -344,17 +342,6 @@ export class CdkTextareaAutosize implements AfterViewInit, DoCheck, OnDestroy { // no-op handler that ensures we're running change detection on input events. } - /** Access injected document if available or fallback to global document reference */ - private _getDocument(): Document { - 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; - } - /** * Scrolls a textarea to the caret position. On Firefox resizing the textarea will * prevent it from scrolling to the caret position. We need to re-set the selection diff --git a/src/material/tabs/paginated-tab-header.ts b/src/material/tabs/paginated-tab-header.ts index 06853a746969..afa004cbb830 100644 --- a/src/material/tabs/paginated-tab-header.ts +++ b/src/material/tabs/paginated-tab-header.ts @@ -10,7 +10,7 @@ import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {ENTER, SPACE, hasModifierKey} from '@angular/cdk/keycodes'; import {SharedResizeObserver} from '@angular/cdk/observers/private'; -import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {Platform, _bindEventWithOptions} from '@angular/cdk/platform'; import {ViewportRuler} from '@angular/cdk/scrolling'; import { ANIMATION_MODULE_TYPE, @@ -27,27 +27,19 @@ import { OnDestroy, Output, QueryList, + Renderer2, afterNextRender, booleanAttribute, inject, numberAttribute, } from '@angular/core'; -import { - EMPTY, - Observable, - Observer, - Subject, - fromEvent, - merge, - of as observableOf, - timer, -} from 'rxjs'; +import {EMPTY, Observable, Observer, Subject, merge, of as observableOf, timer} from 'rxjs'; import {debounceTime, filter, skip, startWith, switchMap, takeUntil} from 'rxjs/operators'; /** Config used to bind passive event listeners */ -const passiveEventListenerOptions = normalizePassiveListenerOptions({ +const passiveEventListenerOptions = { passive: true, -}) as EventListenerOptions; +}; /** * The directions that scrolling can go in when the header's tabs exceed the header width. 'After' @@ -85,7 +77,11 @@ export abstract class MatPaginatedTabHeader private _dir = inject(Directionality, {optional: true}); private _ngZone = inject(NgZone); private _platform = inject(Platform); + private _sharedResizeObserver = inject(SharedResizeObserver); + private _injector = inject(Injector); + private _renderer = inject(Renderer2); _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true}); + private _eventCleanups: (() => void)[]; abstract _items: QueryList; abstract _inkBar: {hide: () => void; alignToElement: (element: HTMLElement) => void}; @@ -163,19 +159,15 @@ export abstract class MatPaginatedTabHeader /** Event emitted when a label is focused. */ @Output() readonly indexFocused: EventEmitter = new EventEmitter(); - private _sharedResizeObserver = inject(SharedResizeObserver); - - private _injector = inject(Injector); - constructor(...args: unknown[]); constructor() { // Bind the `mouseleave` event on the outside since it doesn't change anything in the view. - this._ngZone.runOutsideAngular(() => { - fromEvent(this._elementRef.nativeElement, 'mouseleave') - .pipe(takeUntil(this._destroyed)) - .subscribe(() => this._stopInterval()); - }); + this._eventCleanups = this._ngZone.runOutsideAngular(() => [ + this._renderer.listen(this._elementRef.nativeElement, 'mouseleave', () => + this._stopInterval(), + ), + ]); } /** Called when the user has selected an item via the keyboard. */ @@ -183,17 +175,23 @@ export abstract class MatPaginatedTabHeader ngAfterViewInit() { // We need to handle these events manually, because we want to bind passive event listeners. - fromEvent(this._previousPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) - .pipe(takeUntil(this._destroyed)) - .subscribe(() => { - this._handlePaginatorPress('before'); - }); - fromEvent(this._nextPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) - .pipe(takeUntil(this._destroyed)) - .subscribe(() => { - this._handlePaginatorPress('after'); - }); + this._eventCleanups.push( + _bindEventWithOptions( + this._renderer, + this._previousPaginator.nativeElement, + 'touchstart', + () => this._handlePaginatorPress('before'), + passiveEventListenerOptions, + ), + _bindEventWithOptions( + this._renderer, + this._nextPaginator.nativeElement, + 'touchstart', + () => this._handlePaginatorPress('after'), + passiveEventListenerOptions, + ), + ); } ngAfterContentInit() { @@ -316,6 +314,7 @@ export abstract class MatPaginatedTabHeader } ngOnDestroy() { + this._eventCleanups.forEach(cleanup => cleanup()); this._keyManager?.destroy(); this._destroyed.next(); this._destroyed.complete(); diff --git a/tools/public_api_guard/cdk/menu.md b/tools/public_api_guard/cdk/menu.md index ffab9c7ed73d..c97702cf03a7 100644 --- a/tools/public_api_guard/cdk/menu.md +++ b/tools/public_api_guard/cdk/menu.md @@ -22,6 +22,7 @@ import { OnDestroy } from '@angular/core'; import { Optional } from '@angular/core'; import { OverlayRef } from '@angular/cdk/overlay'; import { QueryList } from '@angular/core'; +import { Renderer2 } from '@angular/core'; import { ScrollStrategy } from '@angular/cdk/overlay'; import { Subject } from 'rxjs'; import { TemplatePortal } from '@angular/cdk/portal'; @@ -97,6 +98,7 @@ export abstract class CdkMenuBase extends CdkMenuGroup implements Menu, AfterCon ngAfterContentInit(): void; // (undocumented) ngOnDestroy(): void; + // (undocumented) protected ngZone: NgZone; orientation: 'horizontal' | 'vertical'; protected pointerTracker?: PointerFocusTracker; @@ -205,6 +207,8 @@ export class CdkMenuTrigger extends CdkMenuTriggerBase implements OnDestroy { close(): void; getMenu(): Menu | undefined; _handleClick(): void; + // (undocumented) + ngOnDestroy(): void; open(): void; _setHasFocus(hasFocus: boolean): void; toggle(): void; @@ -363,8 +367,7 @@ export const PARENT_OR_NEW_MENU_STACK_PROVIDER: { // @public export class PointerFocusTracker { - constructor( - _items: QueryList); + constructor(_renderer: Renderer2, _items: QueryList); activeElement?: T; destroy(): void; readonly entered: Observable; diff --git a/tools/public_api_guard/cdk/scrolling.md b/tools/public_api_guard/cdk/scrolling.md index c75a2e1c7287..287bc7126494 100644 --- a/tools/public_api_guard/cdk/scrolling.md +++ b/tools/public_api_guard/cdk/scrolling.md @@ -63,8 +63,6 @@ export class CdkScrollable implements OnInit, OnDestroy { // (undocumented) protected elementRef: ElementRef; elementScrolled(): Observable; - // (undocumented) - protected _elementScrolled: Observable; getElementRef(): ElementRef; measureScrollOffset(from: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end'): number; // (undocumented) @@ -75,6 +73,8 @@ export class CdkScrollable implements OnInit, OnDestroy { protected ngZone: NgZone; // (undocumented) protected scrollDispatcher: ScrollDispatcher; + // (undocumented) + protected _scrollElement: EventTarget; scrollTo(options: ExtendedScrollToOptions): void; // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; @@ -157,8 +157,6 @@ export class CdkVirtualScrollableElement extends CdkVirtualScrollable { export class CdkVirtualScrollableWindow extends CdkVirtualScrollable { constructor(...args: unknown[]); // (undocumented) - protected _elementScrolled: Observable; - // (undocumented) measureBoundingClientRectWithScrollOffset(from: 'left' | 'top' | 'right' | 'bottom'): number; // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; @@ -265,9 +263,7 @@ export class ScrollDispatcher implements OnDestroy { constructor(...args: unknown[]); ancestorScrolled(elementOrElementRef: ElementRef | HTMLElement, auditTimeInMs?: number): Observable; deregister(scrollable: CdkScrollable): void; - protected _document: Document; getAncestorScrollContainers(elementOrElementRef: ElementRef | HTMLElement): CdkScrollable[]; - _globalSubscription: Subscription | null; // (undocumented) ngOnDestroy(): void; register(scrollable: CdkScrollable): void;