From 83632789feb7c2d049a0408c5e4278bed33124d8 Mon Sep 17 00:00:00 2001 From: wagnermaciel Date: Thu, 18 Mar 2021 14:46:33 -0700 Subject: [PATCH 1/4] fix(material-experimental/mdc-slider): fix change events on slider inputs * create GlobalChangeListener to handle listening for change events that occur on the document * stop all of the slider inputs change events from reaching users * dispatch our own fake change events from #emitChangeEvent in the slider adapter * use the GlobalChangeListener for change events instead of adding our own event listener in #registerInputEventHandler * keep track of and unsubscribe from the GlobalChangeListener in #deregisterInputEventHandler --- .../mdc-slider/global-change-listener.ts | 56 +++++++++++++++++++ .../mdc-slider/slider.ts | 56 +++++++++++++++++-- 2 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/material-experimental/mdc-slider/global-change-listener.ts diff --git a/src/material-experimental/mdc-slider/global-change-listener.ts b/src/material-experimental/mdc-slider/global-change-listener.ts new file mode 100644 index 000000000000..2bcddf8cff8c --- /dev/null +++ b/src/material-experimental/mdc-slider/global-change-listener.ts @@ -0,0 +1,56 @@ +/** + * @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} from '@angular/core'; +import {SpecificEventListener} from '@material/base'; +import {Subject, Subscription} from 'rxjs'; +import {finalize} from 'rxjs/operators'; + +/** + * Handles listening for all change events that occur on the document. + * + * This service exposes a single method #listen to allow users to subscribe to change events that + * occur on the document. Since listening for all change events on the document can be expensive, + * we lazily attach a single event listener to the document when the first subscription is made, + * and remove the event listener once the last observer unsubscribes. + */ +@Injectable({providedIn: 'root'}) +export class GlobalChangeListener { + + /** The injected document if available or fallback to the global document reference. */ + private _document: Document; + + /** Emits change events that occur on the global document. */ + private _change: Subject = new Subject(); + + constructor(@Inject(DOCUMENT) document: any) { + this._document = document; + this._handler = this._handler.bind(this); + } + + /** Emits the given event from the change subject. */ + private _handler(event: Event) { + this._change.next(event); + } + + /** Returns a subscription to global change events. */ + listen (callback: SpecificEventListener): Subscription { + // This is the first subscription to change events. + if (this._change.observers.length === 0) { + this._document.addEventListener('change', this._handler, true); + } + + return this._change.pipe(finalize(() => { + // This is the last change listener unsubscribing. + if (this._change.observers.length === 1) { + this._document.removeEventListener('change', this._handler, true); + } + })).subscribe(callback); + } +} diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts index 4e2929ec2f2e..0b11459d1fd2 100644 --- a/src/material-experimental/mdc-slider/slider.ts +++ b/src/material-experimental/mdc-slider/slider.ts @@ -54,6 +54,7 @@ import { import {SpecificEventListener, EventType} from '@material/base'; import {MDCSliderAdapter, MDCSliderFoundation, Thumb, TickMark} from '@material/slider'; import {Subscription} from 'rxjs'; +import {GlobalChangeListener} from './global-change-listener'; /** Represents a drag event emitted by the MatSlider component. */ export interface MatSliderDragEvent { @@ -374,6 +375,12 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnIn this._blur.emit(); } + _emitChangeEvent() { + const event = new Event('change') as any; + event.isFake = true; + this._hostElement.dispatchEvent(event); + } + /** * Sets the model value. Implemented as part of ControlValueAccessor. * @param value @@ -605,10 +612,11 @@ export class MatSlider extends _MatSliderMixinBase readonly _cdr: ChangeDetectorRef, readonly _elementRef: ElementRef, private readonly _platform: Platform, + readonly _globalChangeListener: GlobalChangeListener, @Inject(DOCUMENT) document: any, @Optional() private _dir: Directionality, @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) - readonly _globalRippleOptions?: RippleGlobalOptions) { + readonly _globalRippleOptions?: RippleGlobalOptions) { super(_elementRef); this._document = document; this._window = this._document.defaultView || window; @@ -756,6 +764,10 @@ export class MatSlider extends _MatSliderMixinBase /** The MDCSliderAdapter implementation. */ class SliderAdapter implements MDCSliderAdapter { + + /** The global change listener subscription used to handle change events on the slider inputs. */ + changeSubscription: Subscription; + constructor(private readonly _delegate: MatSlider) {} // We manually assign functions instead of using prototype methods because @@ -840,11 +852,20 @@ class SliderAdapter implements MDCSliderAdapter { setPointerCapture = (pointerId: number): void => { this._delegate._elementRef.nativeElement.setPointerCapture(pointerId); } - // We ignore emitChangeEvent and emitInputEvent because the slider inputs - // are already exposed so users can just listen for those events directly themselves. emitChangeEvent = (value: number, thumbPosition: Thumb): void => { - this._delegate._getInput(thumbPosition)._onChange(value); + // We block all real slider input change events and emit fake change events from here, instead. + // We do this because the mdc implementation of the slider does not trigger real change events + // on pointer up (only on left or right arrow key down). + // + // By stopping real change events from reaching users, and dispatching fake change events + // (which we allow to reach the user) the slider inputs change events are triggered at the + // appropriate times. This allows users to listen for change events directly on the slider + // input as they would with a native range input. + const input = this._delegate._getInput(thumbPosition); + input._emitChangeEvent(); + input._onChange(value); } + // Users can listen for input events directly on the native html inputs that we expose. emitInputEvent = (value: number, thumbPosition: Thumb): void => {}; emitDragStartEvent = (value: number, thumbPosition: Thumb): void => { const input = this._delegate._getInput(thumbPosition); @@ -872,11 +893,34 @@ class SliderAdapter implements MDCSliderAdapter { } registerInputEventHandler = (thumbPosition: Thumb, evtType: K, handler: SpecificEventListener): void => { - this._delegate._getInputElement(thumbPosition).addEventListener(evtType, handler); + if (evtType === 'change') { + this.changeSubscription = this._delegate._globalChangeListener.listen((event: Event) => { + // We block all real slider input change events and emit fake change events from + // #emitChangeEvent, instead. We do this because the mdc implementation of the slider + // does not trigger real change events on pointer up (only on left or right arrow key + // down). + // + // By stopping real change events from reaching users, and dispatching fake change events + // (which we allow to reach the user) the slider inputs change events are triggered at + // the appropriate times. This allows users to listen for change events directly on the + // slider input as they would with a native range input. + if (event.target === this._delegate._getInputElement(thumbPosition)) { + if ((event as any).isFake) { return; } + event.stopImmediatePropagation(); + handler(event as GlobalEventHandlersEventMap[K]); + } + }); + } else { + this._delegate._getInputElement(thumbPosition).addEventListener(evtType, handler); + } } deregisterInputEventHandler = (thumbPosition: Thumb, evtType: K, handler: SpecificEventListener): void => { - this._delegate._getInputElement(thumbPosition).removeEventListener(evtType, handler); + if (evtType === 'change') { + this.changeSubscription.unsubscribe(); + } else { + this._delegate._getInputElement(thumbPosition).removeEventListener(evtType, handler); + } } registerBodyEventHandler = (evtType: K, handler: SpecificEventListener): void => { From 07e55a1d80e450087bae34fd680ef8905ee5411e Mon Sep 17 00:00:00 2001 From: wagnermaciel Date: Mon, 22 Mar 2021 08:29:26 -0700 Subject: [PATCH 2/4] fixup! fix(material-experimental/mdc-slider): fix change events on slider inputs --- .../global-change-and-input-listener.ts | 68 +++++++++++++++++++ .../mdc-slider/slider.ts | 54 ++++++++------- 2 files changed, 98 insertions(+), 24 deletions(-) create mode 100644 src/material-experimental/mdc-slider/global-change-and-input-listener.ts diff --git a/src/material-experimental/mdc-slider/global-change-and-input-listener.ts b/src/material-experimental/mdc-slider/global-change-and-input-listener.ts new file mode 100644 index 000000000000..5b2ee6b8c77e --- /dev/null +++ b/src/material-experimental/mdc-slider/global-change-and-input-listener.ts @@ -0,0 +1,68 @@ +/** + * @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} from '@angular/core'; +import {SpecificEventListener} from '@material/base'; +import {Subject, Subscription} from 'rxjs'; +import {finalize} from 'rxjs/operators'; + +/** + * Handles listening for all change and input events that occur on the document. + * + * This service exposes a single method #listen to allow users to subscribe to change and input + * events that occur on the document. Since listening for these events on the document can be + * expensive, we lazily attach listeners to the document when the first subscription is made, and + * remove the listeners once the last observer unsubscribes. + */ +@Injectable({providedIn: 'root'}) +export class GlobalChangeAndInputListener { + + /** 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 subjects = new Map>(); + + /** Stores the event handlers that emit the events that occur on the global document. */ + private handlers = new Map void)>(); + + constructor(@Inject(DOCUMENT) document: any) { + this._document = document; + } + + /** Returns a function for handling the given type of event. */ + private _createHandlerFn(type: K): ((event: Event) => void) { + return (event: Event) => { + this.subjects.get(type)!.next(event); + } + } + + /** Returns a subscription to global change or input events. */ + listen(type: K, callback: SpecificEventListener): Subscription { + // This is the first subscription to these events. + if (!this.subjects.get(type)) { + const handler = this._createHandlerFn(type).bind(this); + this.subjects.set(type, new Subject()); + this.handlers.set(type, handler); + this._document.addEventListener(type, handler, true); + } + + const subject = this.subjects.get(type)!; + const handler = this.handlers.get(type)!; + + return subject.pipe(finalize(() => { + // This is the last event listener unsubscribing. + if (subject.observers.length === 1) { + this._document.removeEventListener(type, handler, true); + this.subjects.delete(type); + this.handlers.delete(type); + } + })).subscribe(callback); + } +} diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts index 0b11459d1fd2..6f463dde7bd4 100644 --- a/src/material-experimental/mdc-slider/slider.ts +++ b/src/material-experimental/mdc-slider/slider.ts @@ -54,7 +54,7 @@ import { import {SpecificEventListener, EventType} from '@material/base'; import {MDCSliderAdapter, MDCSliderFoundation, Thumb, TickMark} from '@material/slider'; import {Subscription} from 'rxjs'; -import {GlobalChangeListener} from './global-change-listener'; +import {GlobalChangeAndInputListener} from './global-change-and-input-listener'; /** Represents a drag event emitted by the MatSlider component. */ export interface MatSliderDragEvent { @@ -321,6 +321,12 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnIn /** Event emitted every time the MatSliderThumb is focused. */ @Output() readonly _focus: EventEmitter = new EventEmitter(); + /** Event emitted on pointer up or after left or right arrow key presses. */ + @Output() readonly change: EventEmitter = new EventEmitter(); + + /** Event emitted on each value change that happens to the slider. */ + @Output() readonly input: EventEmitter = new EventEmitter(); + _disabled: boolean = false; /** @@ -375,10 +381,11 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnIn this._blur.emit(); } - _emitChangeEvent() { - const event = new Event('change') as any; + _emitFakeEvent(type: 'change'|'input') { + const event = new Event(type) as any; event.isFake = true; - this._hostElement.dispatchEvent(event); + const emitter = type === 'change' ? this.change : this.input; + emitter.emit(event); } /** @@ -612,7 +619,7 @@ export class MatSlider extends _MatSliderMixinBase readonly _cdr: ChangeDetectorRef, readonly _elementRef: ElementRef, private readonly _platform: Platform, - readonly _globalChangeListener: GlobalChangeListener, + readonly _globalChangeAndInputListener: GlobalChangeAndInputListener<'input'|'change'>, @Inject(DOCUMENT) document: any, @Optional() private _dir: Directionality, @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) @@ -862,11 +869,12 @@ class SliderAdapter implements MDCSliderAdapter { // appropriate times. This allows users to listen for change events directly on the slider // input as they would with a native range input. const input = this._delegate._getInput(thumbPosition); - input._emitChangeEvent(); + input._emitFakeEvent('change'); input._onChange(value); } - // Users can listen for input events directly on the native html inputs that we expose. - emitInputEvent = (value: number, thumbPosition: Thumb): void => {}; + emitInputEvent = (value: number, thumbPosition: Thumb): void => { + this._delegate._getInput(thumbPosition)._emitFakeEvent('input'); + }; emitDragStartEvent = (value: number, thumbPosition: Thumb): void => { const input = this._delegate._getInput(thumbPosition); input.dragStart.emit({ source: input, parent: this._delegate, value }); @@ -893,22 +901,20 @@ class SliderAdapter implements MDCSliderAdapter { } registerInputEventHandler = (thumbPosition: Thumb, evtType: K, handler: SpecificEventListener): void => { - if (evtType === 'change') { - this.changeSubscription = this._delegate._globalChangeListener.listen((event: Event) => { - // We block all real slider input change events and emit fake change events from - // #emitChangeEvent, instead. We do this because the mdc implementation of the slider - // does not trigger real change events on pointer up (only on left or right arrow key - // down). - // - // By stopping real change events from reaching users, and dispatching fake change events - // (which we allow to reach the user) the slider inputs change events are triggered at - // the appropriate times. This allows users to listen for change events directly on the - // slider input as they would with a native range input. - if (event.target === this._delegate._getInputElement(thumbPosition)) { - if ((event as any).isFake) { return; } - event.stopImmediatePropagation(); - handler(event as GlobalEventHandlersEventMap[K]); - } + if (evtType === 'change' || evtType === 'input') { + this.changeSubscription = this._delegate._globalChangeAndInputListener + .listen(evtType as 'change'|'input', (event: Event) => { + // We block all real change and input events and emit fake events from #emitChangeEvent + // and #emitInputEvent, instead. We do this because interacting with the MDC slider + // won't trigger all of the correct change and input events, but it will call + // #emitChangeEvent and #emitInputEvent at the correct times. This allows users to + // listen for these events directly on the slider input as they would with a native + // range input. + if (event.target === this._delegate._getInputElement(thumbPosition)) { + if ((event as any).isFake) { return; } + event.stopImmediatePropagation(); + handler(event as GlobalEventHandlersEventMap[K]); + } }); } else { this._delegate._getInputElement(thumbPosition).addEventListener(evtType, handler); From 41d8d45a7267b3314dba3491b0fcc8919eb54bfc Mon Sep 17 00:00:00 2001 From: wagnermaciel Date: Mon, 22 Mar 2021 08:40:00 -0700 Subject: [PATCH 3/4] fixup! fix(material-experimental/mdc-slider): fix change events on slider inputs --- .../global-change-and-input-listener.ts | 26 +++++++++---------- .../mdc-slider/slider.ts | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/material-experimental/mdc-slider/global-change-and-input-listener.ts b/src/material-experimental/mdc-slider/global-change-and-input-listener.ts index 5b2ee6b8c77e..715b289a96f1 100644 --- a/src/material-experimental/mdc-slider/global-change-and-input-listener.ts +++ b/src/material-experimental/mdc-slider/global-change-and-input-listener.ts @@ -27,10 +27,10 @@ export class GlobalChangeAndInputListener { private _document: Document; /** Stores the subjects that emit the events that occur on the global document. */ - private subjects = new Map>(); + private _subjects = new Map>(); /** Stores the event handlers that emit the events that occur on the global document. */ - private handlers = new Map void)>(); + private _handlers = new Map void)>(); constructor(@Inject(DOCUMENT) document: any) { this._document = document; @@ -39,29 +39,29 @@ export class GlobalChangeAndInputListener { /** Returns a function for handling the given type of event. */ private _createHandlerFn(type: K): ((event: Event) => void) { return (event: Event) => { - this.subjects.get(type)!.next(event); - } + this._subjects.get(type)!.next(event); + }; } /** Returns a subscription to global change or input events. */ listen(type: K, callback: SpecificEventListener): Subscription { // This is the first subscription to these events. - if (!this.subjects.get(type)) { - const handler = this._createHandlerFn(type).bind(this); - this.subjects.set(type, new Subject()); - this.handlers.set(type, handler); - this._document.addEventListener(type, handler, true); + if (!this._subjects.get(type)) { + const handlerFn = this._createHandlerFn(type).bind(this); + this._subjects.set(type, new Subject()); + this._handlers.set(type, handlerFn); + this._document.addEventListener(type, handlerFn, true); } - const subject = this.subjects.get(type)!; - const handler = this.handlers.get(type)!; + const subject = this._subjects.get(type)!; + const handler = this._handlers.get(type)!; return subject.pipe(finalize(() => { // This is the last event listener unsubscribing. if (subject.observers.length === 1) { this._document.removeEventListener(type, handler, true); - this.subjects.delete(type); - this.handlers.delete(type); + this._subjects.delete(type); + this._handlers.delete(type); } })).subscribe(callback); } diff --git a/src/material-experimental/mdc-slider/slider.ts b/src/material-experimental/mdc-slider/slider.ts index 6f463dde7bd4..01db485fe637 100644 --- a/src/material-experimental/mdc-slider/slider.ts +++ b/src/material-experimental/mdc-slider/slider.ts @@ -874,7 +874,7 @@ class SliderAdapter implements MDCSliderAdapter { } emitInputEvent = (value: number, thumbPosition: Thumb): void => { this._delegate._getInput(thumbPosition)._emitFakeEvent('input'); - }; + } emitDragStartEvent = (value: number, thumbPosition: Thumb): void => { const input = this._delegate._getInput(thumbPosition); input.dragStart.emit({ source: input, parent: this._delegate, value }); From 85ae7809c78d428841bd0d975cdb35fb35678850 Mon Sep 17 00:00:00 2001 From: wagnermaciel Date: Tue, 23 Mar 2021 07:55:03 -0700 Subject: [PATCH 4/4] fixup! fix(material-experimental/mdc-slider): fix change events on slider inputs --- .../global-change-and-input-listener.ts | 8 ++- .../mdc-slider/global-change-listener.ts | 56 ------------------- 2 files changed, 5 insertions(+), 59 deletions(-) delete mode 100644 src/material-experimental/mdc-slider/global-change-listener.ts diff --git a/src/material-experimental/mdc-slider/global-change-and-input-listener.ts b/src/material-experimental/mdc-slider/global-change-and-input-listener.ts index 715b289a96f1..3fcfc8b8b04d 100644 --- a/src/material-experimental/mdc-slider/global-change-and-input-listener.ts +++ b/src/material-experimental/mdc-slider/global-change-and-input-listener.ts @@ -7,7 +7,7 @@ */ import {DOCUMENT} from '@angular/common'; -import {Inject, Injectable} from '@angular/core'; +import {Inject, Injectable, NgZone} from '@angular/core'; import {SpecificEventListener} from '@material/base'; import {Subject, Subscription} from 'rxjs'; import {finalize} from 'rxjs/operators'; @@ -32,7 +32,7 @@ export class GlobalChangeAndInputListener { /** Stores the event handlers that emit the events that occur on the global document. */ private _handlers = new Map void)>(); - constructor(@Inject(DOCUMENT) document: any) { + constructor(@Inject(DOCUMENT) document: any, private _ngZone: NgZone) { this._document = document; } @@ -50,7 +50,9 @@ export class GlobalChangeAndInputListener { const handlerFn = this._createHandlerFn(type).bind(this); this._subjects.set(type, new Subject()); this._handlers.set(type, handlerFn); - this._document.addEventListener(type, handlerFn, true); + this._ngZone.runOutsideAngular(() => { + this._document.addEventListener(type, handlerFn, true); + }); } const subject = this._subjects.get(type)!; diff --git a/src/material-experimental/mdc-slider/global-change-listener.ts b/src/material-experimental/mdc-slider/global-change-listener.ts deleted file mode 100644 index 2bcddf8cff8c..000000000000 --- a/src/material-experimental/mdc-slider/global-change-listener.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @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} from '@angular/core'; -import {SpecificEventListener} from '@material/base'; -import {Subject, Subscription} from 'rxjs'; -import {finalize} from 'rxjs/operators'; - -/** - * Handles listening for all change events that occur on the document. - * - * This service exposes a single method #listen to allow users to subscribe to change events that - * occur on the document. Since listening for all change events on the document can be expensive, - * we lazily attach a single event listener to the document when the first subscription is made, - * and remove the event listener once the last observer unsubscribes. - */ -@Injectable({providedIn: 'root'}) -export class GlobalChangeListener { - - /** The injected document if available or fallback to the global document reference. */ - private _document: Document; - - /** Emits change events that occur on the global document. */ - private _change: Subject = new Subject(); - - constructor(@Inject(DOCUMENT) document: any) { - this._document = document; - this._handler = this._handler.bind(this); - } - - /** Emits the given event from the change subject. */ - private _handler(event: Event) { - this._change.next(event); - } - - /** Returns a subscription to global change events. */ - listen (callback: SpecificEventListener): Subscription { - // This is the first subscription to change events. - if (this._change.observers.length === 0) { - this._document.addEventListener('change', this._handler, true); - } - - return this._change.pipe(finalize(() => { - // This is the last change listener unsubscribing. - if (this._change.observers.length === 1) { - this._document.removeEventListener('change', this._handler, true); - } - })).subscribe(callback); - } -}