diff --git a/.pullapprove.yml b/.pullapprove.yml index 4b9523367017f3..9f8066522f06dd 100644 --- a/.pullapprove.yml +++ b/.pullapprove.yml @@ -243,6 +243,8 @@ groups: 'aio/content/examples/component-interaction/**/{*,.*}', 'aio/content/images/guide/component-interaction/**/{*,.*}', 'aio/content/guide/component-overview.md', + 'aio/content/examples/custom-events/**/{*,.*}', + 'aio/content/guide/custom-events-management.md', 'aio/content/errors/*.md', 'aio/content/examples/errors/**/{*,.*}', 'aio/content/examples/component-overview/**/{*,.*}', diff --git a/aio/content/examples/custom-events/BUILD.bazel b/aio/content/examples/custom-events/BUILD.bazel new file mode 100644 index 00000000000000..507219e360c903 --- /dev/null +++ b/aio/content/examples/custom-events/BUILD.bazel @@ -0,0 +1,7 @@ +load("//aio/content/examples:examples.bzl", "docs_example") + +package(default_visibility = ["//visibility:public"]) + +docs_example( + name = "custom-events", +) diff --git a/aio/content/examples/custom-events/e2e/src/app.e2e-spec.ts b/aio/content/examples/custom-events/e2e/src/app.e2e-spec.ts new file mode 100644 index 00000000000000..f026ffd9e04366 --- /dev/null +++ b/aio/content/examples/custom-events/e2e/src/app.e2e-spec.ts @@ -0,0 +1,11 @@ +import { browser, element, by } from 'protractor'; + +describe('Component Overview', () => { + + beforeAll(() => browser.get('')); + + it('should show the propagation stopped button ', async () => { + expect(await element(by.css('#stoping')).getText()).toEqual('Propagation stopped'); + }); + +}); diff --git a/aio/content/examples/custom-events/example-config.json b/aio/content/examples/custom-events/example-config.json new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/aio/content/examples/custom-events/src/app/app.component.ts b/aio/content/examples/custom-events/src/app/app.component.ts new file mode 100644 index 00000000000000..59a076083a9abf --- /dev/null +++ b/aio/content/examples/custom-events/src/app/app.component.ts @@ -0,0 +1,19 @@ +import {Component, HostListener} from '@angular/core'; +import {ChildComponent} from './child.component'; + +@Component({ + selector: 'app-root', + standalone: true, + imports: [ChildComponent], + template: ` + + `, +}) +export class AppComponent { + name = 'Angular'; + + @HostListener('click', ['$event']) + onClick(event: Event) { + console.log('click parent', event); + } +} diff --git a/aio/content/examples/custom-events/src/app/child.component.ts b/aio/content/examples/custom-events/src/app/child.component.ts new file mode 100644 index 00000000000000..e9154feeec3d98 --- /dev/null +++ b/aio/content/examples/custom-events/src/app/child.component.ts @@ -0,0 +1,16 @@ +import {Component} from '@angular/core'; + +@Component({ + selector: 'app-child', + standalone: true, + template: ` + +
+ + `, +}) +export class ChildComponent { + onClick(event: Event) { + console.log('click child', event?.target); + } +} diff --git a/aio/content/examples/custom-events/src/app/stop-event-plugin.ts b/aio/content/examples/custom-events/src/app/stop-event-plugin.ts new file mode 100644 index 00000000000000..e85d068b563294 --- /dev/null +++ b/aio/content/examples/custom-events/src/app/stop-event-plugin.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import {Injectable} from '@angular/core'; +import {EventManagerPlugin} from '@angular/platform-browser'; + +@Injectable() +export class StopEventPlugin extends EventManagerPlugin { + // #docregion supports + supports(event: string): boolean { + return event.endsWith('.stop'); + } + // #enddocregion supports + + // #docregion addEventListener + addEventListener(element: HTMLElement, eventName: string, handler: (event: Event) => void): Function { + const eventWrapper = (event: Event) => { + event.stopPropagation(); + handler(event); + }; + + // striping the stop modifier from the event name; + const nonModifiedEventName = eventName.replace(/\.stop$/, ''); + + return this.manager.addEventListener(element, nonModifiedEventName, eventWrapper); + } + // #enddocregion addEventListener +} diff --git a/aio/content/examples/custom-events/src/index.html b/aio/content/examples/custom-events/src/index.html new file mode 100644 index 00000000000000..37a08c916952b7 --- /dev/null +++ b/aio/content/examples/custom-events/src/index.html @@ -0,0 +1,13 @@ + + + + + ContentProjection + + + + + + + + diff --git a/aio/content/examples/custom-events/src/main.ts b/aio/content/examples/custom-events/src/main.ts new file mode 100644 index 00000000000000..2ff02a03968ac0 --- /dev/null +++ b/aio/content/examples/custom-events/src/main.ts @@ -0,0 +1,12 @@ +import { bootstrapApplication, EVENT_MANAGER_PLUGINS } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { StopEventPlugin } from './app/stop-event-plugin'; + + +bootstrapApplication(AppComponent, { + // #docregion providers + providers: [ + { provide: EVENT_MANAGER_PLUGINS, multi: true, useClass: StopEventPlugin} + ] + // #enddocregion providers +}); diff --git a/aio/content/examples/custom-events/stackblitz.json b/aio/content/examples/custom-events/stackblitz.json new file mode 100644 index 00000000000000..d4e56b1c165411 --- /dev/null +++ b/aio/content/examples/custom-events/stackblitz.json @@ -0,0 +1,10 @@ +{ + "description": "Custom Events", + "files": [ + "!**/*.d.ts", + "!**/*.js", + "!**/*.[1,2].*" + ], + "file": "src/app/app.component.ts", + "tags": ["Custom Events"] +} diff --git a/aio/content/examples/examples.bzl b/aio/content/examples/examples.bzl index 2206c9e64fc57a..0b4d0483816a00 100644 --- a/aio/content/examples/examples.bzl +++ b/aio/content/examples/examples.bzl @@ -33,6 +33,7 @@ EXAMPLES = { "component-overview": {"stackblitz": True, "zip": True}, "component-styles": {"stackblitz": True, "zip": True}, "content-projection": {"stackblitz": True, "zip": True}, + "custom-events": {"stackblitz": True, "zip": True}, "dependency-injection": {"stackblitz": True, "zip": True}, "dependency-injection-in-action": {"stackblitz": True, "zip": True}, "deprecation-guide": {"stackblitz": True, "zip": True}, diff --git a/aio/content/guide/custom-events-management.md b/aio/content/guide/custom-events-management.md new file mode 100644 index 00000000000000..fae2b99f698264 --- /dev/null +++ b/aio/content/guide/custom-events-management.md @@ -0,0 +1,57 @@ +# Custom Events Handeling + +Angular allows you to handle custom events, to extend existing ones or support new ones. + +## Handling user Events + +[Event Binding](/guide/event-binding) and the the `HostListener` decorator lets you listen for and respond to DOM events and components events. + + + + + <navbutton (click)="onClick($event)">You can click me<nav/button> + + +## Event Handling by the EventManager + +Angular handles events using the `EventManager` service. It has a set of plugins that extend the `EventManagerPlugin` abstract class and delegates event subscription and handling to a plugin that supports particular events. + +Angular has a few built-in plugins such as one dedicated to [HammerJS](/api/platform-browser/HammerModule) events or the `KeyEventsPlugin` responsible for handling composite events such as `keydown.enter`. + +### `EventManagerPlugin` + +`EventManagerPlugin` is an abstract class with 2 methods to implement `supports` and `addEventListener`. + +Plugings are loaded using the `EVENT_MANAGER_PLUGINS` injection token and are provided to the `BrowserModule` using `multi: true`. + +For the `EventManager` to determine which plugin will handle the event, each plugin implements the `supports` method. +The event manager will call `addEventListener` on the first plugin where `supports` returns `true`. + +## Handle a custom event + +Let's implement a plugin that will register events and call `Event.stopPropagation()` on them. +This plugin will add the support for the `.stop` modifier and we'll be able to register events like `click.stop`. + +1. `support` needs to return `true` if the stop modifier is present : + + + +2. Wrap the event's handler to `stopPropagration` and register the wrapper. +3. Extract the original event name and registrer the event with the manager. + + + +3. Add the plugin to the list of providers of the app. + + + + +You can now listen to the new custom events via [Event bindings](/guide/event-binding) or the `HostListener` decorator. + + + <navbutton (click.stop)="onClick($event)">Propagation stopped<nav/button> + + + + +Try this . diff --git a/aio/content/guide/event-binding.md b/aio/content/guide/event-binding.md index fabd52c722a93b..faa43c1aa55b74 100644 --- a/aio/content/guide/event-binding.md +++ b/aio/content/guide/event-binding.md @@ -87,5 +87,6 @@ For more information, visit the full reference for [key](https://developer.mozil * [Property binding](guide/property-binding) * [Text interpolation](guide/interpolation) * [Two-way binding](guide/two-way-binding) +* [Handle custom events](guide/custom-events-management) @reviewed 2022-05-10 diff --git a/goldens/public-api/platform-browser/index.md b/goldens/public-api/platform-browser/index.md index df3ccad791ab8b..fe700f876fd12f 100644 --- a/goldens/public-api/platform-browser/index.md +++ b/goldens/public-api/platform-browser/index.md @@ -92,6 +92,18 @@ export class EventManager { static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public +export abstract class EventManagerPlugin { + abstract addEventListener(element: HTMLElement, eventName: string, handler: (event: Event) => void): Function; + // (undocumented) + manager: EventManager; + abstract supports(eventName: string): boolean; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; +} + // @public export const HAMMER_GESTURE_CONFIG: InjectionToken; diff --git a/packages/core/test/render3/imported_renderer2.ts b/packages/core/test/render3/imported_renderer2.ts index 4bb5312bede2d3..e8bc0a61b53d8b 100644 --- a/packages/core/test/render3/imported_renderer2.ts +++ b/packages/core/test/render3/imported_renderer2.ts @@ -17,10 +17,6 @@ import {EventManagerPlugin} from '@angular/platform-browser/src/dom/events/event import {isTextNode} from '@angular/platform-browser/testing/src/browser_util'; export class SimpleDomEventsPlugin extends EventManagerPlugin { - constructor(doc: any) { - super(doc); - } - override supports(eventName: string): boolean { return true; } @@ -38,7 +34,7 @@ export class SimpleDomEventsPlugin extends EventManagerPlugin { export function getRendererFactory2(document: any): RendererFactory2 { const fakeNgZone: NgZone = new NoopNgZone(); - const eventManager = new EventManager([new SimpleDomEventsPlugin(document)], fakeNgZone); + const eventManager = new EventManager([new SimpleDomEventsPlugin()], fakeNgZone); const appId = 'appid'; const rendererFactory = new ɵDomRendererFactory2( eventManager, new ɵSharedStylesHost(document, appId), appId, true, document, diff --git a/packages/platform-browser/src/dom/events/dom_events.ts b/packages/platform-browser/src/dom/events/dom_events.ts index e3826727e3dd55..5a3d82b2725240 100644 --- a/packages/platform-browser/src/dom/events/dom_events.ts +++ b/packages/platform-browser/src/dom/events/dom_events.ts @@ -6,24 +6,20 @@ * found in the LICENSE file at https://angular.io/license */ -import {DOCUMENT} from '@angular/common'; -import {Inject, Injectable} from '@angular/core'; +import {Injectable} from '@angular/core'; import {EventManagerPlugin} from './event_manager'; @Injectable() export class DomEventsPlugin extends EventManagerPlugin { - constructor(@Inject(DOCUMENT) doc: any) { - super(doc); - } - // This plugin should come last in the list of plugins, because it accepts all // events. override supports(eventName: string): boolean { return true; } - override addEventListener(element: HTMLElement, eventName: string, handler: Function): Function { + override addEventListener( + element: HTMLElement, eventName: string, handler: (event: Event) => void): () => void { element.addEventListener(eventName, handler as EventListener, false); return () => this.removeEventListener(element, eventName, handler as EventListener); } diff --git a/packages/platform-browser/src/dom/events/event_manager.ts b/packages/platform-browser/src/dom/events/event_manager.ts index 1784364c688bc4..e81ad987058a51 100644 --- a/packages/platform-browser/src/dom/events/event_manager.ts +++ b/packages/platform-browser/src/dom/events/event_manager.ts @@ -9,7 +9,7 @@ import {Inject, Injectable, InjectionToken, NgZone} from '@angular/core'; /** - * The injection token for the event-manager plug-in service. + * The injection token for plugins of the `EventManager` service. * * @publicApi */ @@ -48,7 +48,7 @@ export class EventManager { */ addEventListener(element: HTMLElement, eventName: string, handler: Function): Function { const plugin = this._findPluginFor(eventName); - return plugin.addEventListener(element, eventName, handler); + return plugin.addEventListener(element, eventName, handler as (event: Event) => void); } /** @@ -77,13 +77,24 @@ export class EventManager { } } +/** + * The plugin definition for the `EventManager` class + * + * @publicApi + */ +@Injectable() export abstract class EventManagerPlugin { - constructor(private _doc: any) {} - // Using non-null assertion because it's set by EventManager's constructor manager!: EventManager; + /** + * Should return `true` for every event name that should be supported by this plugin + */ abstract supports(eventName: string): boolean; - abstract addEventListener(element: HTMLElement, eventName: string, handler: Function): Function; + /** + * Implement the behaviour for the supported events + */ + abstract addEventListener( + element: HTMLElement, eventName: string, handler: (event: Event) => void): Function; } diff --git a/packages/platform-browser/src/dom/events/hammer_gestures.ts b/packages/platform-browser/src/dom/events/hammer_gestures.ts index 8084b5b81d0917..dea53a193cca86 100644 --- a/packages/platform-browser/src/dom/events/hammer_gestures.ts +++ b/packages/platform-browser/src/dom/events/hammer_gestures.ts @@ -165,10 +165,9 @@ export class HammerGesturesPlugin extends EventManagerPlugin { private _loaderPromise: Promise|null = null; constructor( - @Inject(DOCUMENT) doc: any, @Inject(HAMMER_GESTURE_CONFIG) private _config: HammerGestureConfig, private console: Console, @Optional() @Inject(HAMMER_LOADER) private loader?: HammerLoader|null) { - super(doc); + super(); } override supports(eventName: string): boolean { @@ -272,7 +271,7 @@ export class HammerGesturesPlugin extends EventManagerPlugin { * HammerJS to detect gesture events. * * Note that applications still need to include the HammerJS script itself. This module - * simply sets up the coordination layer between HammerJS and Angular's EventManager. + * simply sets up the coordination layer between HammerJS and Angular's `EventManager`. * * @publicApi */ diff --git a/packages/platform-browser/src/dom/events/key_events.ts b/packages/platform-browser/src/dom/events/key_events.ts index f9810ab893d671..18b4196dc63c09 100644 --- a/packages/platform-browser/src/dom/events/key_events.ts +++ b/packages/platform-browser/src/dom/events/key_events.ts @@ -54,8 +54,9 @@ export class KeyEventsPlugin extends EventManagerPlugin { * Initializes an instance of the browser plug-in. * @param doc The document in which key events will be detected. */ + // TODO: doc is unused and should be remove in the next major. constructor(@Inject(DOCUMENT) doc: any) { - super(doc); + super(); } /** diff --git a/packages/platform-browser/src/platform-browser.ts b/packages/platform-browser/src/platform-browser.ts index 2efc7402599ea0..de878f7d926735 100644 --- a/packages/platform-browser/src/platform-browser.ts +++ b/packages/platform-browser/src/platform-browser.ts @@ -73,7 +73,7 @@ export {Title} from './browser/title'; export {disableDebugTools, enableDebugTools} from './browser/tools/tools'; export {By} from './dom/debug/by'; export {REMOVE_STYLES_ON_COMPONENT_DESTROY} from './dom/dom_renderer'; -export {EVENT_MANAGER_PLUGINS, EventManager} from './dom/events/event_manager'; +export {EVENT_MANAGER_PLUGINS, EventManager, EventManagerPlugin} from './dom/events/event_manager'; export {HAMMER_GESTURE_CONFIG, HAMMER_LOADER, HammerGestureConfig, HammerLoader, HammerModule} from './dom/events/hammer_gestures'; export {DomSanitizer, SafeHtml, SafeResourceUrl, SafeScript, SafeStyle, SafeUrl, SafeValue} from './security/dom_sanitization_service'; export {HydrationFeature, provideClientHydration, HydrationFeatureKind, withNoDomReuse, withNoHttpTransferCache} from './hydration'; diff --git a/packages/platform-browser/test/dom/events/event_manager_spec.ts b/packages/platform-browser/test/dom/events/event_manager_spec.ts index 7f7aca8f06f0d1..16968a898c575f 100644 --- a/packages/platform-browser/test/dom/events/event_manager_spec.ts +++ b/packages/platform-browser/test/dom/events/event_manager_spec.ts @@ -23,14 +23,14 @@ describe('EventManager', () => { beforeEach(() => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({}); - domEventPlugin = new DomEventsPlugin(doc); + domEventPlugin = new DomEventsPlugin(); }); it('should delegate event bindings to plugins that are passed in from the most generic one to the most specific one', () => { const element = el('
'); const handler = (e: any /** TODO #9100 */) => e; - const plugin = new FakeEventManagerPlugin(doc, ['click']); + const plugin = new FakeEventManagerPlugin(['click']); const manager = new EventManager([domEventPlugin, plugin], new FakeNgZone()); manager.addEventListener(element, 'click', handler); expect(plugin.eventHandler['click']).toBe(handler); @@ -40,8 +40,8 @@ describe('EventManager', () => { const element = el('
'); const clickHandler = (e: any /** TODO #9100 */) => e; const dblClickHandler = (e: any /** TODO #9100 */) => e; - const plugin1 = new FakeEventManagerPlugin(doc, ['dblclick']); - const plugin2 = new FakeEventManagerPlugin(doc, ['click', 'dblclick']); + const plugin1 = new FakeEventManagerPlugin(['dblclick']); + const plugin2 = new FakeEventManagerPlugin(['click', 'dblclick']); const manager = new EventManager([plugin2, plugin1], new FakeNgZone()); manager.addEventListener(element, 'click', clickHandler); manager.addEventListener(element, 'dblclick', dblClickHandler); @@ -51,7 +51,7 @@ describe('EventManager', () => { it('should throw when no plugin can handle the event', () => { const element = el('
'); - const plugin = new FakeEventManagerPlugin(doc, ['dblclick']); + const plugin = new FakeEventManagerPlugin(['dblclick']); const manager = new EventManager([plugin], new FakeNgZone()); expect(() => manager.addEventListener(element, 'click', null!)) .toThrowError('No event manager plugin found for event click'); @@ -326,7 +326,7 @@ describe('EventManager', () => { (done: DoneFn) => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({shouldCoalesceEventChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); + domEventPlugin = new DomEventsPlugin(); const element = el('
'); const child = el('
'); element.appendChild(child); @@ -363,7 +363,7 @@ describe('EventManager', () => { (done: DoneFn) => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({shouldCoalesceRunChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); + domEventPlugin = new DomEventsPlugin(); const element = el('
'); const child = el('
'); element.appendChild(child); @@ -400,7 +400,7 @@ describe('EventManager', () => { (done: DoneFn) => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({shouldCoalesceEventChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); + domEventPlugin = new DomEventsPlugin(); const element = el('
'); const child = el('
'); doc.body.appendChild(element); @@ -446,7 +446,7 @@ describe('EventManager', () => { (done: DoneFn) => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({shouldCoalesceRunChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); + domEventPlugin = new DomEventsPlugin(); const element = el('
'); const child = el('
'); doc.body.appendChild(element); @@ -494,8 +494,8 @@ describe('EventManager', () => { class FakeEventManagerPlugin extends EventManagerPlugin { eventHandler: {[event: string]: Function} = {}; - constructor(doc: any, public supportedEvents: string[]) { - super(doc); + constructor(public supportedEvents: string[]) { + super(); } override supports(eventName: string): boolean { diff --git a/packages/platform-browser/test/dom/events/hammer_gestures_spec.ts b/packages/platform-browser/test/dom/events/hammer_gestures_spec.ts index c319ea5ef3bb0a..4064fa4c662529 100644 --- a/packages/platform-browser/test/dom/events/hammer_gestures_spec.ts +++ b/packages/platform-browser/test/dom/events/hammer_gestures_spec.ts @@ -27,7 +27,7 @@ import {HammerGestureConfig, HammerGesturesPlugin,} from '@angular/platform-brow describe('with no custom loader', () => { beforeEach(() => { - plugin = new HammerGesturesPlugin(document, new HammerGestureConfig(), fakeConsole); + plugin = new HammerGesturesPlugin(new HammerGestureConfig(), fakeConsole); }); it('should warn user and do nothing when Hammer.js not loaded', () => { @@ -88,7 +88,7 @@ import {HammerGestureConfig, HammerGesturesPlugin,} from '@angular/platform-brow const hammerConfig = new HammerGestureConfig(); spyOn(hammerConfig, 'buildHammer').and.returnValue(fakeHammerInstance); - plugin = new HammerGesturesPlugin(document, hammerConfig, fakeConsole, loader); + plugin = new HammerGesturesPlugin(hammerConfig, fakeConsole, loader); // Use a fake EventManager that has access to the NgZone. plugin.manager = {getZone: () => ngZone} as EventManager; diff --git a/packages/platform-server/src/server_events.ts b/packages/platform-server/src/server_events.ts index ea66012b0ed294..08eac783e5705a 100644 --- a/packages/platform-server/src/server_events.ts +++ b/packages/platform-server/src/server_events.ts @@ -6,19 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {DOCUMENT, ɵgetDOM as getDOM} from '@angular/common'; -import {Inject, Injectable} from '@angular/core'; +import {ɵgetDOM as getDOM} from '@angular/common'; +import {Injectable} from '@angular/core'; +import {EventManagerPlugin} from '@angular/platform-browser'; @Injectable() -export class ServerEventManagerPlugin /* extends EventManagerPlugin which is private */ { - constructor(@Inject(DOCUMENT) private doc: any) {} - +export class ServerEventManagerPlugin extends EventManagerPlugin { // Handle all events on the server. - supports(eventName: string) { + override supports(eventName: string) { return true; } - addEventListener(element: HTMLElement, eventName: string, handler: Function): Function { + override addEventListener(element: HTMLElement, eventName: string, handler: Function): Function { return getDOM().onAndCancel(element, eventName, handler); } }