diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a06da14083e1..a9706001c1ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -106,6 +106,7 @@ /src/cdk/collections/** @crisbeto @andrewseguin /src/cdk/dialog/** @jelbourn @crisbeto /src/cdk/drag-drop/** @crisbeto +/src/cdk/global-listener/** @wagnermaciel /src/cdk/keycodes/** @andrewseguin /src/cdk/layout/** @andrewseguin /src/cdk/listbox/** @jelbourn diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index 1e82ea3e64ac..0696bfeb8d3e 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -23,6 +23,7 @@ export const commitMessage: CommitMessageConfig = { 'cdk/collections', 'cdk/dialog', 'cdk/drag-drop', + 'cdk/global-listener', 'cdk/keycodes', 'cdk/layout', 'cdk/listbox', diff --git a/src/cdk/config.bzl b/src/cdk/config.bzl index ef490d23be14..e236d3e7f495 100644 --- a/src/cdk/config.bzl +++ b/src/cdk/config.bzl @@ -8,6 +8,7 @@ CDK_ENTRYPOINTS = [ "collections", "dialog", "drag-drop", + "global-listener", "keycodes", "layout", "listbox", diff --git a/src/cdk/global-listener/BUILD.bazel b/src/cdk/global-listener/BUILD.bazel new file mode 100644 index 000000000000..f2ca26f09a2a --- /dev/null +++ b/src/cdk/global-listener/BUILD.bazel @@ -0,0 +1,45 @@ +load("//tools:defaults.bzl", "markdown_to_html", "ng_module", "ng_test_library", "ng_web_test_suite") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "global-listener", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//rxjs", + ], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":global-listener", + "@npm//@angular/common", + "@npm//@angular/platform-browser", + "@npm//rxjs", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) + +markdown_to_html( + name = "overview", + srcs = [":global-listener.md"], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) diff --git a/src/cdk/global-listener/global-listener.md b/src/cdk/global-listener/global-listener.md new file mode 100644 index 000000000000..773e98b06e33 --- /dev/null +++ b/src/cdk/global-listener/global-listener.md @@ -0,0 +1,57 @@ +The global listener is a service designed to optimize listening by reducing the number of event +listeners attached to the DOM. + +### GlobalListener.listen() + +The `GlobalListener.listen()` is intended to be a more performant replacement for basic uses of +`EventTarget.addEventListener()`. `GlobalListener` lazily attaches a single event listener to the +`document` and only triggers the given callback if the event happens to the specified element or +one of its children. + +#### Drawbacks + +- Does not trigger callbacks in the same order that `EventTarget.addEventListener()` would. +- Uses passive event listening which means that the callback function specified can never call +`Event.preventDefault()`. +- Listens to the capture phase which means that events will be dispatched to the given handlers +before being dispatched to any `EventTarget` in the DOM tree. + + + + +#### Basic Usage + +In the example below, MyButton is listening for 'click' events on the host button element. Even if +we render 100 buttons in the DOM, because MyButton uses `GlobalListener.listen()` the number of +event listeners will still be one. + +```typescript +import {Directive, ElementRef, OnDestroy} from '@angular/core'; +import {Subscription} from 'rxjs'; +import {GlobalListener} from '@angular/cdk/global-listener'; + +@Directive({ + selector: 'button[my-button]', +}) +class MyButton implements OnDestroy { + private _subscription: Subscription; + + constructor( + readonly globalListener: GlobalListener, + readonly elementRef: ElementRef, + ) { + this._subscription = globalListener.listen('click', elementRef.nativeElement, event => { + this.onClick(event); + }); + } + + ngOnDestroy() { + this._subscription.unsubscribe(); + } + + onClick(event: Event) { + console.log('click!', event); + } +} +``` + diff --git a/src/cdk/global-listener/global-listener.spec.ts b/src/cdk/global-listener/global-listener.spec.ts new file mode 100644 index 000000000000..a4595b72e282 --- /dev/null +++ b/src/cdk/global-listener/global-listener.spec.ts @@ -0,0 +1,100 @@ +import { + Component, + Directive, + ElementRef, + OnDestroy, + QueryList, + ViewChild, + ViewChildren, +} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {Subscription} from 'rxjs'; +import {GlobalListener} from './global-listener'; + +describe('GlobalListener', () => { + let fixture: ComponentFixture; + let myButtons: QueryList; + + beforeEach(() => { + TestBed.configureTestingModule({declarations: [ButtonDemo, MyButton]}).compileComponents(); + fixture = TestBed.createComponent(ButtonDemo); + fixture.detectChanges(); + myButtons = fixture.componentInstance.myButtons; + }); + + it('should call the click handler when a click event occurs', () => { + const button = myButtons.get(0)!; + spyOn(button, 'onClick'); + expect(button.onClick).not.toHaveBeenCalled(); + + button.elementRef.nativeElement.click(); + expect(button.onClick).toHaveBeenCalledTimes(1); + + button.elementRef.nativeElement.click(); + button.elementRef.nativeElement.click(); + expect(button.onClick).toHaveBeenCalledTimes(3); + }); + + it('should only call the handler for the button that the event happened on', () => { + const button0 = myButtons.get(0)!; + const button1 = myButtons.get(1)!; + + spyOn(button0, 'onClick'); + spyOn(button1, 'onClick'); + + button1.elementRef.nativeElement.click(); + + expect(button0.onClick).toHaveBeenCalledTimes(0); + expect(button1.onClick).toHaveBeenCalledTimes(1); + + button0.elementRef.nativeElement.click(); + button0.elementRef.nativeElement.click(); + + expect(button0.onClick).toHaveBeenCalledTimes(2); + expect(button1.onClick).toHaveBeenCalledTimes(1); + }); + + it('should call the handler if the event target is a child of the specified element', () => { + const buttonText = fixture.componentInstance.buttonText.nativeElement; + const button = myButtons.get(2)!; + spyOn(button, 'onClick'); + expect(button.onClick).toHaveBeenCalledTimes(0); + + buttonText.click(); + expect(button.onClick).toHaveBeenCalledTimes(1); + }); +}); + +@Directive({ + selector: 'button[my-button]', +}) +class MyButton implements OnDestroy { + private _subscription: Subscription; + + constructor( + readonly globalListener: GlobalListener, + readonly elementRef: ElementRef, + ) { + this._subscription = globalListener.listen('click', elementRef.nativeElement, event => { + this.onClick(event); + }); + } + + ngOnDestroy() { + this._subscription.unsubscribe(); + } + + onClick(_: Event) {} +} + +@Component({ + template: ` + + + + `, +}) +export class ButtonDemo { + @ViewChildren(MyButton) myButtons: QueryList; + @ViewChild('buttonText') buttonText: ElementRef; +} diff --git a/src/cdk/global-listener/global-listener.ts b/src/cdk/global-listener/global-listener.ts new file mode 100644 index 000000000000..cde9e5db581c --- /dev/null +++ b/src/cdk/global-listener/global-listener.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {DOCUMENT} from '@angular/common'; +import {Inject, Injectable, NgZone, OnDestroy} from '@angular/core'; +import {fromEvent, Observable, Subject, Subscription} from 'rxjs'; +import {finalize, share, takeUntil} from 'rxjs/operators'; + +/** + * Provides a global listener for all events that occur on the document. + * + * This service exposes a single method #listen to allow users to subscribe to events that occur on + * the document. We use #fromEvent which will lazily attach a listener when the first subscription + * is made and remove the listener once the last observer unsubscribes. + */ +@Injectable({providedIn: 'root'}) +export class GlobalListener implements OnDestroy { + /** The injected document if available or fallback to the global document reference. */ + private _document: Document; + + /** Stores the subjects that emit the events that occur on the global document. */ + private _observables = new Map>(); + + /** The notifier that triggers the global event observables to stop emitting and complete. */ + private _destroyed = new Subject(); + + constructor(@Inject(DOCUMENT) document: any, private _ngZone: NgZone) { + this._document = document; + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + this._observables.clear(); + } + + /** + * Appends an event listener for events whose type attribute value is type. + * The callback argument sets the callback that will be invoked when the event is dispatched. + */ + listen( + type: keyof DocumentEventMap, + element: HTMLElement, + listener: (ev: Event) => any, + ): Subscription { + // If this is the first time we are listening to this event, create the observable for it. + if (!this._observables.has(type)) { + this._observables.set(type, this._createGlobalEventObservable(type)); + } + + return this._ngZone.runOutsideAngular(() => + this._observables.get(type)!.subscribe((event: Event) => + this._ngZone.run(() => { + if (event.target instanceof Node && element.contains(event.target)) { + listener(event); + } + }), + ), + ); + } + + /** Creates an observable that emits all events of the given type. */ + private _createGlobalEventObservable(type: keyof DocumentEventMap) { + return fromEvent(this._document, type, {passive: true, capture: true}).pipe( + takeUntil(this._destroyed), + finalize(() => this._observables.delete(type)), + share(), + ); + } +} diff --git a/src/cdk/global-listener/index.ts b/src/cdk/global-listener/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/cdk/global-listener/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/cdk/global-listener/public-api.ts b/src/cdk/global-listener/public-api.ts new file mode 100644 index 000000000000..02fd7d2ac4b5 --- /dev/null +++ b/src/cdk/global-listener/public-api.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export {GlobalListener} from './global-listener'; diff --git a/tools/public_api_guard/cdk/global-listener.md b/tools/public_api_guard/cdk/global-listener.md new file mode 100644 index 000000000000..3d3b64032a4f --- /dev/null +++ b/tools/public_api_guard/cdk/global-listener.md @@ -0,0 +1,26 @@ +## API Report File for "components-srcs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as i0 from '@angular/core'; +import { NgZone } from '@angular/core'; +import { OnDestroy } from '@angular/core'; +import { Subscription } from 'rxjs'; + +// @public +export class GlobalListener implements OnDestroy { + constructor(document: any, _ngZone: NgZone); + listen(type: keyof DocumentEventMap, element: HTMLElement, listener: (ev: Event) => any): Subscription; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; +} + +// (No @packageDocumentation comment for this package) + +```