Skip to content

Commit

Permalink
refactor(multiple): use renderer for manually-bound events with options
Browse files Browse the repository at this point in the history
Switches all manually-bound event handlers that were passing options to go through the renderer.
  • Loading branch information
crisbeto committed Dec 17, 2024
1 parent dbc2e21 commit 36bdbd8
Show file tree
Hide file tree
Showing 13 changed files with 460 additions and 335 deletions.
45 changes: 31 additions & 14 deletions src/cdk-experimental/popover-edit/table-directives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import {
TemplateRef,
ViewContainerRef,
inject,
Renderer2,
ListenerOptions,
} from '@angular/core';
import {fromEvent, fromEventPattern, merge, Subject} from 'rxjs';
import {merge, Observable, Subject} from 'rxjs';
import {
debounceTime,
filter,
Expand All @@ -44,6 +46,7 @@ import {
} from './focus-escape-notifier';
import {closest} from './polyfill';
import {EditRef} from './edit-ref';
import {_bindEventWithOptions} from '@angular/cdk/platform';

/**
* Describes the number of columns before and after the originating cell that the
Expand Down Expand Up @@ -73,6 +76,7 @@ export class CdkEditable implements AfterViewInit, OnDestroy {
inject<EditEventDispatcher<EditRef<unknown>>>(EditEventDispatcher);
protected readonly focusDispatcher = inject(FocusDispatcher);
protected readonly ngZone = inject(NgZone);
private readonly _renderer = inject(Renderer2);

protected readonly destroyed = new Subject<void>();

Expand All @@ -94,20 +98,37 @@ export class CdkEditable implements AfterViewInit, OnDestroy {
this._rendered.complete();
}

private _observableFromEvent<T extends Event>(
element: Element,
name: string,
options?: ListenerOptions,
) {
return new Observable<T>(subscriber => {
const handler = (event: T) => subscriber.next(event);
const cleanup = options
? _bindEventWithOptions(this._renderer, element, name, handler, options)
: this._renderer.listen(element, name, handler, options);
return () => {
cleanup();
subscriber.complete();
};
});
}

private _listenForTableEvents(): void {
const element = this.elementRef.nativeElement;
const toClosest = (selector: string) =>
map((event: UIEvent) => closest(event.target, selector));

this.ngZone.runOutsideAngular(() => {
// Track mouse movement over the table to hide/show hover content.
fromEvent<MouseEvent>(element, 'mouseover')
this._observableFromEvent<MouseEvent>(element, 'mouseover')
.pipe(toClosest(ROW_SELECTOR), takeUntil(this.destroyed))
.subscribe(this.editEventDispatcher.hovering);
fromEvent<MouseEvent>(element, 'mouseleave')
this._observableFromEvent<MouseEvent>(element, 'mouseleave')
.pipe(mapTo(null), takeUntil(this.destroyed))
.subscribe(this.editEventDispatcher.hovering);
fromEvent<MouseEvent>(element, 'mousemove')
this._observableFromEvent<MouseEvent>(element, 'mousemove')
.pipe(
throttleTime(MOUSE_MOVE_THROTTLE_TIME_MS),
toClosest(ROW_SELECTOR),
Expand All @@ -116,19 +137,15 @@ export class CdkEditable implements AfterViewInit, OnDestroy {
.subscribe(this.editEventDispatcher.mouseMove);

// Track focus within the table to hide/show/make focusable hover content.
fromEventPattern<FocusEvent>(
handler => element.addEventListener('focus', handler, true),
handler => element.removeEventListener('focus', handler, true),
)
this._observableFromEvent<FocusEvent>(element, 'focus', {capture: true})
.pipe(toClosest(ROW_SELECTOR), share(), takeUntil(this.destroyed))
.subscribe(this.editEventDispatcher.focused);

merge(
fromEventPattern<FocusEvent>(
handler => element.addEventListener('blur', handler, true),
handler => element.removeEventListener('blur', handler, true),
this._observableFromEvent(element, 'blur', {capture: true}),
this._observableFromEvent<KeyboardEvent>(element, 'keydown').pipe(
filter(event => event.key === 'Escape'),
),
fromEvent<KeyboardEvent>(element, 'keydown').pipe(filter(event => event.key === 'Escape')),
)
.pipe(mapTo(null), share(), takeUntil(this.destroyed))
.subscribe(this.editEventDispatcher.focused);
Expand All @@ -150,7 +167,7 @@ export class CdkEditable implements AfterViewInit, OnDestroy {
)
.subscribe(this.editEventDispatcher.allRows);

fromEvent<KeyboardEvent>(element, 'keydown')
this._observableFromEvent<KeyboardEvent>(element, 'keydown')
.pipe(
filter(event => event.key === 'Enter'),
toClosest(CELL_SELECTOR),
Expand All @@ -159,7 +176,7 @@ export class CdkEditable implements AfterViewInit, OnDestroy {
.subscribe(this.editEventDispatcher.editing);

// Keydown must be used here or else key auto-repeat does not work properly on some platforms.
fromEvent<KeyboardEvent>(element, 'keydown')
this._observableFromEvent<KeyboardEvent>(element, 'keydown')
.pipe(takeUntil(this.destroyed))
.subscribe(this.focusDispatcher.keyObserver);
});
Expand Down
94 changes: 49 additions & 45 deletions src/cdk/a11y/focus-monitor/focus-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

import {
Platform,
normalizePassiveListenerOptions,
_getShadowRoot,
_getEventTarget,
_bindEventWithOptions,
} from '@angular/cdk/platform';
import {
Directive,
Expand All @@ -23,6 +23,7 @@ import {
Output,
AfterViewInit,
inject,
RendererFactory2,
} from '@angular/core';
import {Observable, of as observableOf, Subject, Subscription} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
Expand Down Expand Up @@ -76,16 +77,18 @@ type MonitoredElementInfo = {
* Event listener options that enable capturing and also
* mark the listener as passive if the browser supports it.
*/
const captureEventListenerOptions = normalizePassiveListenerOptions({
const captureEventListenerOptions = {
passive: true,
capture: true,
});
};

/** Monitors mouse and keyboard events to determine the cause of focus events. */
@Injectable({providedIn: 'root'})
export class FocusMonitor implements OnDestroy {
private _ngZone = inject(NgZone);
private _platform = inject(Platform);
private _renderer = inject(RendererFactory2).createRenderer(null, null);
private _cleanupWindowFocus: (() => void) | undefined;
private readonly _inputModalityDetector = inject(InputModalityDetector);

/** The focus origin that the next focus event is a result of. */
Expand Down Expand Up @@ -121,7 +124,13 @@ export class FocusMonitor implements OnDestroy {
* handlers differently from the rest of the events, because the browser won't emit events
* to the document when focus moves inside of a shadow root.
*/
private _rootNodeFocusListenerCount = new Map<HTMLElement | Document | ShadowRoot, number>();
private _rootNodeFocusListeners = new Map<
HTMLElement | Document | ShadowRoot,
{
count: number;
cleanups: (() => void)[];
}
>();

/**
* The specified detection mode, used for attributing the origin of a focus
Expand Down Expand Up @@ -307,12 +316,6 @@ export class FocusMonitor implements OnDestroy {
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;
}

private _getFocusOrigin(focusEventTarget: HTMLElement | null): FocusOrigin {
if (this._origin) {
// If the origin was realized via a touch interaction, we need to perform additional checks
Expand Down Expand Up @@ -468,32 +471,45 @@ export class FocusMonitor implements OnDestroy {
}

const rootNode = elementInfo.rootNode;
const rootNodeFocusListeners = this._rootNodeFocusListenerCount.get(rootNode) || 0;
const listeners = this._rootNodeFocusListeners.get(rootNode);

if (!rootNodeFocusListeners) {
if (listeners) {
listeners.count++;
} else {
this._ngZone.runOutsideAngular(() => {
rootNode.addEventListener(
'focus',
this._rootNodeFocusAndBlurListener,
captureEventListenerOptions,
);
rootNode.addEventListener(
'blur',
this._rootNodeFocusAndBlurListener,
captureEventListenerOptions,
);
this._rootNodeFocusListeners.set(rootNode, {
count: 1,
cleanups: [
_bindEventWithOptions(
this._renderer,
rootNode,
'focus',
this._rootNodeFocusAndBlurListener,
captureEventListenerOptions,
),
_bindEventWithOptions(
this._renderer,
rootNode,
'blur',
this._rootNodeFocusAndBlurListener,
captureEventListenerOptions,
),
],
});
});
}

this._rootNodeFocusListenerCount.set(rootNode, rootNodeFocusListeners + 1);

// Register global listeners when first element is monitored.
if (++this._monitoredElementCount === 1) {
// Note: we listen to events in the capture phase so we
// can detect them even if the user stops propagation.
this._ngZone.runOutsideAngular(() => {
const window = this._getWindow();
window.addEventListener('focus', this._windowFocusListener);
this._cleanupWindowFocus?.();
this._cleanupWindowFocus = this._renderer.listen(
'window',
'focus',
this._windowFocusListener,
);
});

// The InputModalityDetector is also just a collection of global listeners.
Expand All @@ -506,32 +522,20 @@ export class FocusMonitor implements OnDestroy {
}

private _removeGlobalListeners(elementInfo: MonitoredElementInfo) {
const rootNode = elementInfo.rootNode;
const listeners = this._rootNodeFocusListeners.get(elementInfo.rootNode);

if (this._rootNodeFocusListenerCount.has(rootNode)) {
const rootNodeFocusListeners = this._rootNodeFocusListenerCount.get(rootNode)!;

if (rootNodeFocusListeners > 1) {
this._rootNodeFocusListenerCount.set(rootNode, rootNodeFocusListeners - 1);
if (listeners) {
if (listeners.count > 1) {
listeners.count--;
} else {
rootNode.removeEventListener(
'focus',
this._rootNodeFocusAndBlurListener,
captureEventListenerOptions,
);
rootNode.removeEventListener(
'blur',
this._rootNodeFocusAndBlurListener,
captureEventListenerOptions,
);
this._rootNodeFocusListenerCount.delete(rootNode);
listeners.cleanups.forEach(cleanup => cleanup());
this._rootNodeFocusListeners.delete(elementInfo.rootNode);
}
}

// Unregister global listeners when last element is unmonitored.
if (!--this._monitoredElementCount) {
const window = this._getWindow();
window.removeEventListener('focus', this._windowFocusListener);
this._cleanupWindowFocus?.();

// Equivalently, stop our InputModalityDetector subscription.
this._stopInputModalityDetector.next();
Expand Down
53 changes: 39 additions & 14 deletions src/cdk/a11y/input-modality/input-modality-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@
*/

import {ALT, CONTROL, MAC_META, META, SHIFT} from '@angular/cdk/keycodes';
import {Injectable, InjectionToken, OnDestroy, NgZone, inject} from '@angular/core';
import {normalizePassiveListenerOptions, Platform, _getEventTarget} from '@angular/cdk/platform';
import {
Injectable,
InjectionToken,
OnDestroy,
NgZone,
inject,
RendererFactory2,
} from '@angular/core';
import {Platform, _bindEventWithOptions, _getEventTarget} from '@angular/cdk/platform';
import {DOCUMENT} from '@angular/common';
import {BehaviorSubject, Observable} from 'rxjs';
import {distinctUntilChanged, skip} from 'rxjs/operators';
Expand Down Expand Up @@ -69,10 +76,10 @@ export const TOUCH_BUFFER_MS = 650;
* Event listener options that enable capturing and also mark the listener as passive if the browser
* supports it.
*/
const modalityEventListenerOptions = normalizePassiveListenerOptions({
const modalityEventListenerOptions = {
passive: true,
capture: true,
});
};

/**
* Service that detects the user's input modality.
Expand All @@ -91,6 +98,7 @@ const modalityEventListenerOptions = normalizePassiveListenerOptions({
@Injectable({providedIn: 'root'})
export class InputModalityDetector implements OnDestroy {
private readonly _platform = inject(Platform);
private readonly _listenerCleanups: (() => void)[] | undefined;

/** Emits whenever an input modality is detected. */
readonly modalityDetected: Observable<InputModality>;
Expand Down Expand Up @@ -193,21 +201,38 @@ export class InputModalityDetector implements OnDestroy {
// If we're not in a browser, this service should do nothing, as there's no relevant input
// modality to detect.
if (this._platform.isBrowser) {
ngZone.runOutsideAngular(() => {
document.addEventListener('keydown', this._onKeydown, modalityEventListenerOptions);
document.addEventListener('mousedown', this._onMousedown, modalityEventListenerOptions);
document.addEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions);
const renderer = inject(RendererFactory2).createRenderer(null, null);

this._listenerCleanups = ngZone.runOutsideAngular(() => {
return [
_bindEventWithOptions(
renderer,
document,
'keydown',
this._onKeydown,
modalityEventListenerOptions,
),
_bindEventWithOptions(
renderer,
document,
'mousedown',
this._onMousedown,
modalityEventListenerOptions,
),
_bindEventWithOptions(
renderer,
document,
'touchstart',
this._onTouchstart,
modalityEventListenerOptions,
),
];
});
}
}

ngOnDestroy() {
this._modality.complete();

if (this._platform.isBrowser) {
document.removeEventListener('keydown', this._onKeydown, modalityEventListenerOptions);
document.removeEventListener('mousedown', this._onMousedown, modalityEventListenerOptions);
document.removeEventListener('touchstart', this._onTouchstart, modalityEventListenerOptions);
}
this._listenerCleanups?.forEach(cleanup => cleanup());
}
}
Loading

0 comments on commit 36bdbd8

Please sign in to comment.