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..3fcfc8b8b04d --- /dev/null +++ b/src/material-experimental/mdc-slider/global-change-and-input-listener.ts @@ -0,0 +1,70 @@ +/** + * @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} 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, private _ngZone: NgZone) { + 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 handlerFn = this._createHandlerFn(type).bind(this); + this._subjects.set(type, new Subject()); + this._handlers.set(type, handlerFn); + this._ngZone.runOutsideAngular(() => { + this._document.addEventListener(type, handlerFn, 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 4e2929ec2f2e..01db485fe637 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 {GlobalChangeAndInputListener} from './global-change-and-input-listener'; /** Represents a drag event emitted by the MatSlider component. */ export interface MatSliderDragEvent { @@ -320,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; /** @@ -374,6 +381,13 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnIn this._blur.emit(); } + _emitFakeEvent(type: 'change'|'input') { + const event = new Event(type) as any; + event.isFake = true; + const emitter = type === 'change' ? this.change : this.input; + emitter.emit(event); + } + /** * Sets the model value. Implemented as part of ControlValueAccessor. * @param value @@ -605,10 +619,11 @@ export class MatSlider extends _MatSliderMixinBase readonly _cdr: ChangeDetectorRef, readonly _elementRef: ElementRef, private readonly _platform: Platform, + readonly _globalChangeAndInputListener: GlobalChangeAndInputListener<'input'|'change'>, @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 +771,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,12 +859,22 @@ 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._emitFakeEvent('change'); + input._onChange(value); + } + emitInputEvent = (value: number, thumbPosition: Thumb): void => { + this._delegate._getInput(thumbPosition)._emitFakeEvent('input'); } - emitInputEvent = (value: number, thumbPosition: Thumb): void => {}; emitDragStartEvent = (value: number, thumbPosition: Thumb): void => { const input = this._delegate._getInput(thumbPosition); input.dragStart.emit({ source: input, parent: this._delegate, value }); @@ -872,11 +901,32 @@ class SliderAdapter implements MDCSliderAdapter { } registerInputEventHandler = (thumbPosition: Thumb, evtType: K, handler: SpecificEventListener): void => { - this._delegate._getInputElement(thumbPosition).addEventListener(evtType, handler); + 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); + } } 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 => {