Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cdk/global-listener): initial global listener implementation #26314

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .ng-dev/commit-message.mts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const commitMessage: CommitMessageConfig = {
'cdk/collections',
'cdk/dialog',
'cdk/drag-drop',
'cdk/global-listener',
'cdk/keycodes',
'cdk/layout',
'cdk/listbox',
Expand Down
1 change: 1 addition & 0 deletions src/cdk/config.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ CDK_ENTRYPOINTS = [
"collections",
"dialog",
"drag-drop",
"global-listener",
"keycodes",
"layout",
"listbox",
Expand Down
45 changes: 45 additions & 0 deletions src/cdk/global-listener/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"]),
)
57 changes: 57 additions & 0 deletions src/cdk/global-listener/global-listener.md
Original file line number Diff line number Diff line change
@@ -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.


<!-- example(cdk-global-listener-overview) -->

#### 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<HTMLInputElement>,
) {
this._subscription = globalListener.listen('click', elementRef.nativeElement, event => {
this.onClick(event);
});
}

ngOnDestroy() {
this._subscription.unsubscribe();
}

onClick(event: Event) {
console.log('click!', event);
}
}
```

100 changes: 100 additions & 0 deletions src/cdk/global-listener/global-listener.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ButtonDemo>;
let myButtons: QueryList<MyButton>;

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<HTMLInputElement>,
) {
this._subscription = globalListener.listen('click', elementRef.nativeElement, event => {
this.onClick(event);
});
}

ngOnDestroy() {
this._subscription.unsubscribe();
}

onClick(_: Event) {}
}

@Component({
template: `
<button my-button>Button #1</button>
<button my-button>Button #2</button>
<button my-button><span #buttonText>Button #3</span></button>
`,
})
export class ButtonDemo {
@ViewChildren(MyButton) myButtons: QueryList<MyButton>;
@ViewChild('buttonText') buttonText: ElementRef<HTMLElement>;
}
75 changes: 75 additions & 0 deletions src/cdk/global-listener/global-listener.ts
Original file line number Diff line number Diff line change
@@ -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<keyof DocumentEventMap, Observable<Event>>();

/** 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(),
);
}
}
9 changes: 9 additions & 0 deletions src/cdk/global-listener/index.ts
Original file line number Diff line number Diff line change
@@ -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';
9 changes: 9 additions & 0 deletions src/cdk/global-listener/public-api.ts
Original file line number Diff line number Diff line change
@@ -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';
26 changes: 26 additions & 0 deletions tools/public_api_guard/cdk/global-listener.md
Original file line number Diff line number Diff line change
@@ -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<GlobalListener, never>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<GlobalListener>;
}

// (No @packageDocumentation comment for this package)

```