From e93f8d4422e205300686ea9aca75989e94e6e6a9 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 22 Dec 2022 19:34:20 +0000 Subject: [PATCH 1/3] feat(cdk/global-listener): initial global listener implementation --- .github/CODEOWNERS | 1 + .ng-dev/commit-message.mts | 1 + src/cdk/config.bzl | 1 + src/cdk/global-listener/BUILD.bazel | 35 ++++++++ .../global-listener/global-listener.spec.ts | 83 +++++++++++++++++++ src/cdk/global-listener/global-listener.ts | 75 +++++++++++++++++ src/cdk/global-listener/index.ts | 9 ++ src/cdk/global-listener/public-api.ts | 9 ++ tools/public_api_guard/cdk/global-listener.md | 26 ++++++ 9 files changed, 240 insertions(+) create mode 100644 src/cdk/global-listener/BUILD.bazel create mode 100644 src/cdk/global-listener/global-listener.spec.ts create mode 100644 src/cdk/global-listener/global-listener.ts create mode 100644 src/cdk/global-listener/index.ts create mode 100644 src/cdk/global-listener/public-api.ts create mode 100644 tools/public_api_guard/cdk/global-listener.md 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..29d0717cccc4 --- /dev/null +++ b/src/cdk/global-listener/BUILD.bazel @@ -0,0 +1,35 @@ +load("//tools:defaults.bzl", "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"], +) 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..d6ffe9c6ee3b --- /dev/null +++ b/src/cdk/global-listener/global-listener.spec.ts @@ -0,0 +1,83 @@ +import {Component, Directive, ElementRef, OnDestroy, QueryList, 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 mockButtons: QueryList; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [MockButtonDemo, MockButton], + }).compileComponents(); + fixture = TestBed.createComponent(MockButtonDemo); + fixture.detectChanges(); + mockButtons = fixture.componentInstance.mockButtons; + }); + + it('should call the click handler when a click event occurs', () => { + const button = mockButtons.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 = mockButtons.get(0)!; + const button1 = mockButtons.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); + }); +}); + +@Directive({ + selector: '[mock-button]', + host: {class: 'mock-button'}, +}) +class MockButton 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 MockButtonDemo { + @ViewChildren(MockButton) mockButtons: QueryList; +} diff --git a/src/cdk/global-listener/global-listener.ts b/src/cdk/global-listener/global-listener.ts new file mode 100644 index 000000000000..1f5b6da4bbf3 --- /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 === element) { + 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}).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) + +``` From 695261cd39260c099b57e8b46927c5b02ce842b5 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 22 Dec 2022 21:48:12 +0000 Subject: [PATCH 2/3] fixup! feat(cdk/global-listener): initial global listener implementation --- src/cdk/global-listener/BUILD.bazel | 7 ++- src/cdk/global-listener/global-listener.md | 57 +++++++++++++++++++ .../global-listener/global-listener.spec.ts | 53 +++++++++++------ src/cdk/global-listener/global-listener.ts | 4 +- 4 files changed, 100 insertions(+), 21 deletions(-) create mode 100644 src/cdk/global-listener/global-listener.md diff --git a/src/cdk/global-listener/BUILD.bazel b/src/cdk/global-listener/BUILD.bazel index 29d0717cccc4..4444e63c4931 100644 --- a/src/cdk/global-listener/BUILD.bazel +++ b/src/cdk/global-listener/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite") +load("//tools:defaults.bzl", "markdown_to_html", "ng_module", "ng_test_library", "ng_web_test_suite") package(default_visibility = ["//visibility:public"]) @@ -33,3 +33,8 @@ ng_web_test_suite( name = "unit_tests", deps = [":unit_test_sources"], ) + +markdown_to_html( + name = "overview", + srcs = [":global-listener.md"], +) 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 index d6ffe9c6ee3b..a4595b72e282 100644 --- a/src/cdk/global-listener/global-listener.spec.ts +++ b/src/cdk/global-listener/global-listener.spec.ts @@ -1,23 +1,29 @@ -import {Component, Directive, ElementRef, OnDestroy, QueryList, ViewChildren} from '@angular/core'; +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 mockButtons: QueryList; + let fixture: ComponentFixture; + let myButtons: QueryList; beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [MockButtonDemo, MockButton], - }).compileComponents(); - fixture = TestBed.createComponent(MockButtonDemo); + TestBed.configureTestingModule({declarations: [ButtonDemo, MyButton]}).compileComponents(); + fixture = TestBed.createComponent(ButtonDemo); fixture.detectChanges(); - mockButtons = fixture.componentInstance.mockButtons; + myButtons = fixture.componentInstance.myButtons; }); it('should call the click handler when a click event occurs', () => { - const button = mockButtons.get(0)!; + const button = myButtons.get(0)!; spyOn(button, 'onClick'); expect(button.onClick).not.toHaveBeenCalled(); @@ -30,8 +36,8 @@ describe('GlobalListener', () => { }); it('should only call the handler for the button that the event happened on', () => { - const button0 = mockButtons.get(0)!; - const button1 = mockButtons.get(1)!; + const button0 = myButtons.get(0)!; + const button1 = myButtons.get(1)!; spyOn(button0, 'onClick'); spyOn(button1, 'onClick'); @@ -47,13 +53,22 @@ describe('GlobalListener', () => { 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: '[mock-button]', - host: {class: 'mock-button'}, + selector: 'button[my-button]', }) -class MockButton implements OnDestroy { +class MyButton implements OnDestroy { private _subscription: Subscription; constructor( @@ -74,10 +89,12 @@ class MockButton implements OnDestroy { @Component({ template: ` - - + + + `, }) -export class MockButtonDemo { - @ViewChildren(MockButton) mockButtons: QueryList; +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 index 1f5b6da4bbf3..cde9e5db581c 100644 --- a/src/cdk/global-listener/global-listener.ts +++ b/src/cdk/global-listener/global-listener.ts @@ -56,7 +56,7 @@ export class GlobalListener implements OnDestroy { return this._ngZone.runOutsideAngular(() => this._observables.get(type)!.subscribe((event: Event) => this._ngZone.run(() => { - if (event.target === element) { + if (event.target instanceof Node && element.contains(event.target)) { listener(event); } }), @@ -66,7 +66,7 @@ export class GlobalListener implements OnDestroy { /** Creates an observable that emits all events of the given type. */ private _createGlobalEventObservable(type: keyof DocumentEventMap) { - return fromEvent(this._document, type, {passive: true}).pipe( + return fromEvent(this._document, type, {passive: true, capture: true}).pipe( takeUntil(this._destroyed), finalize(() => this._observables.delete(type)), share(), From aba265615f4ddfc09fabf83abe75b7b812c29549 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Thu, 22 Dec 2022 21:51:48 +0000 Subject: [PATCH 3/3] fixup! feat(cdk/global-listener): initial global listener implementation --- src/cdk/global-listener/BUILD.bazel | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/cdk/global-listener/BUILD.bazel b/src/cdk/global-listener/BUILD.bazel index 4444e63c4931..f2ca26f09a2a 100644 --- a/src/cdk/global-listener/BUILD.bazel +++ b/src/cdk/global-listener/BUILD.bazel @@ -38,3 +38,8 @@ markdown_to_html( name = "overview", srcs = [":global-listener.md"], ) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +)