Skip to content

Commit f395dd2

Browse files
committed
fix(material-experimental/mdc-slider): fix change events on slider in… (angular#22286)
* fix(material-experimental/mdc-slider): fix change events on slider inputs * create GlobalChangeAndInputListener 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 GlobalChangeAndInputListener for change events instead of adding our own event listener in #registerInputEventHandler * keep track of and unsubscribe from the GlobalChangeAndInputListener in #deregisterInputEventHandler
1 parent 826e9f1 commit f395dd2

File tree

2 files changed

+127
-7
lines changed

2 files changed

+127
-7
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {DOCUMENT} from '@angular/common';
10+
import {Inject, Injectable, NgZone} from '@angular/core';
11+
import {SpecificEventListener} from '@material/base';
12+
import {Subject, Subscription} from 'rxjs';
13+
import {finalize} from 'rxjs/operators';
14+
15+
/**
16+
* Handles listening for all change and input events that occur on the document.
17+
*
18+
* This service exposes a single method #listen to allow users to subscribe to change and input
19+
* events that occur on the document. Since listening for these events on the document can be
20+
* expensive, we lazily attach listeners to the document when the first subscription is made, and
21+
* remove the listeners once the last observer unsubscribes.
22+
*/
23+
@Injectable({providedIn: 'root'})
24+
export class GlobalChangeAndInputListener<K extends 'change'|'input'> {
25+
26+
/** The injected document if available or fallback to the global document reference. */
27+
private _document: Document;
28+
29+
/** Stores the subjects that emit the events that occur on the global document. */
30+
private _subjects = new Map<K, Subject<Event>>();
31+
32+
/** Stores the event handlers that emit the events that occur on the global document. */
33+
private _handlers = new Map<K, ((event: Event) => void)>();
34+
35+
constructor(@Inject(DOCUMENT) document: any, private _ngZone: NgZone) {
36+
this._document = document;
37+
}
38+
39+
/** Returns a function for handling the given type of event. */
40+
private _createHandlerFn(type: K): ((event: Event) => void) {
41+
return (event: Event) => {
42+
this._subjects.get(type)!.next(event);
43+
};
44+
}
45+
46+
/** Returns a subscription to global change or input events. */
47+
listen(type: K, callback: SpecificEventListener<K>): Subscription {
48+
// This is the first subscription to these events.
49+
if (!this._subjects.get(type)) {
50+
const handlerFn = this._createHandlerFn(type).bind(this);
51+
this._subjects.set(type, new Subject<Event>());
52+
this._handlers.set(type, handlerFn);
53+
this._ngZone.runOutsideAngular(() => {
54+
this._document.addEventListener(type, handlerFn, true);
55+
});
56+
}
57+
58+
const subject = this._subjects.get(type)!;
59+
const handler = this._handlers.get(type)!;
60+
61+
return subject.pipe(finalize(() => {
62+
// This is the last event listener unsubscribing.
63+
if (subject.observers.length === 1) {
64+
this._document.removeEventListener(type, handler, true);
65+
this._subjects.delete(type);
66+
this._handlers.delete(type);
67+
}
68+
})).subscribe(callback);
69+
}
70+
}

src/material-experimental/mdc-slider/slider.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
import {SpecificEventListener, EventType} from '@material/base';
5555
import {MDCSliderAdapter, MDCSliderFoundation, Thumb, TickMark} from '@material/slider';
5656
import {Subscription} from 'rxjs';
57+
import {GlobalChangeAndInputListener} from './global-change-and-input-listener';
5758

5859
/** Represents a drag event emitted by the MatSlider component. */
5960
export interface MatSliderDragEvent {
@@ -320,6 +321,12 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnIn
320321
/** Event emitted every time the MatSliderThumb is focused. */
321322
@Output() readonly _focus: EventEmitter<void> = new EventEmitter<void>();
322323

324+
/** Event emitted on pointer up or after left or right arrow key presses. */
325+
@Output() readonly change: EventEmitter<Event> = new EventEmitter<Event>();
326+
327+
/** Event emitted on each value change that happens to the slider. */
328+
@Output() readonly input: EventEmitter<Event> = new EventEmitter<Event>();
329+
323330
_disabled: boolean = false;
324331

325332
/**
@@ -374,6 +381,13 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnIn
374381
this._blur.emit();
375382
}
376383

384+
_emitFakeEvent(type: 'change'|'input') {
385+
const event = new Event(type) as any;
386+
event.isFake = true;
387+
const emitter = type === 'change' ? this.change : this.input;
388+
emitter.emit(event);
389+
}
390+
377391
/**
378392
* Sets the model value. Implemented as part of ControlValueAccessor.
379393
* @param value
@@ -605,10 +619,11 @@ export class MatSlider extends _MatSliderMixinBase
605619
readonly _cdr: ChangeDetectorRef,
606620
readonly _elementRef: ElementRef<HTMLElement>,
607621
private readonly _platform: Platform,
622+
readonly _globalChangeAndInputListener: GlobalChangeAndInputListener<'input'|'change'>,
608623
@Inject(DOCUMENT) document: any,
609624
@Optional() private _dir: Directionality,
610625
@Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS)
611-
readonly _globalRippleOptions?: RippleGlobalOptions) {
626+
readonly _globalRippleOptions?: RippleGlobalOptions) {
612627
super(_elementRef);
613628
this._document = document;
614629
this._window = this._document.defaultView || window;
@@ -756,6 +771,10 @@ export class MatSlider extends _MatSliderMixinBase
756771

757772
/** The MDCSliderAdapter implementation. */
758773
class SliderAdapter implements MDCSliderAdapter {
774+
775+
/** The global change listener subscription used to handle change events on the slider inputs. */
776+
changeSubscription: Subscription;
777+
759778
constructor(private readonly _delegate: MatSlider) {}
760779

761780
// We manually assign functions instead of using prototype methods because
@@ -840,12 +859,22 @@ class SliderAdapter implements MDCSliderAdapter {
840859
setPointerCapture = (pointerId: number): void => {
841860
this._delegate._elementRef.nativeElement.setPointerCapture(pointerId);
842861
}
843-
// We ignore emitChangeEvent and emitInputEvent because the slider inputs
844-
// are already exposed so users can just listen for those events directly themselves.
845862
emitChangeEvent = (value: number, thumbPosition: Thumb): void => {
846-
this._delegate._getInput(thumbPosition)._onChange(value);
863+
// We block all real slider input change events and emit fake change events from here, instead.
864+
// We do this because the mdc implementation of the slider does not trigger real change events
865+
// on pointer up (only on left or right arrow key down).
866+
//
867+
// By stopping real change events from reaching users, and dispatching fake change events
868+
// (which we allow to reach the user) the slider inputs change events are triggered at the
869+
// appropriate times. This allows users to listen for change events directly on the slider
870+
// input as they would with a native range input.
871+
const input = this._delegate._getInput(thumbPosition);
872+
input._emitFakeEvent('change');
873+
input._onChange(value);
874+
}
875+
emitInputEvent = (value: number, thumbPosition: Thumb): void => {
876+
this._delegate._getInput(thumbPosition)._emitFakeEvent('input');
847877
}
848-
emitInputEvent = (value: number, thumbPosition: Thumb): void => {};
849878
emitDragStartEvent = (value: number, thumbPosition: Thumb): void => {
850879
const input = this._delegate._getInput(thumbPosition);
851880
input.dragStart.emit({ source: input, parent: this._delegate, value });
@@ -872,11 +901,32 @@ class SliderAdapter implements MDCSliderAdapter {
872901
}
873902
registerInputEventHandler = <K extends EventType>
874903
(thumbPosition: Thumb, evtType: K, handler: SpecificEventListener<K>): void => {
875-
this._delegate._getInputElement(thumbPosition).addEventListener(evtType, handler);
904+
if (evtType === 'change' || evtType === 'input') {
905+
this.changeSubscription = this._delegate._globalChangeAndInputListener
906+
.listen(evtType as 'change'|'input', (event: Event) => {
907+
// We block all real change and input events and emit fake events from #emitChangeEvent
908+
// and #emitInputEvent, instead. We do this because interacting with the MDC slider
909+
// won't trigger all of the correct change and input events, but it will call
910+
// #emitChangeEvent and #emitInputEvent at the correct times. This allows users to
911+
// listen for these events directly on the slider input as they would with a native
912+
// range input.
913+
if (event.target === this._delegate._getInputElement(thumbPosition)) {
914+
if ((event as any).isFake) { return; }
915+
event.stopImmediatePropagation();
916+
handler(event as GlobalEventHandlersEventMap[K]);
917+
}
918+
});
919+
} else {
920+
this._delegate._getInputElement(thumbPosition).addEventListener(evtType, handler);
921+
}
876922
}
877923
deregisterInputEventHandler = <K extends EventType>
878924
(thumbPosition: Thumb, evtType: K, handler: SpecificEventListener<K>): void => {
879-
this._delegate._getInputElement(thumbPosition).removeEventListener(evtType, handler);
925+
if (evtType === 'change') {
926+
this.changeSubscription.unsubscribe();
927+
} else {
928+
this._delegate._getInputElement(thumbPosition).removeEventListener(evtType, handler);
929+
}
880930
}
881931
registerBodyEventHandler =
882932
<K extends EventType>(evtType: K, handler: SpecificEventListener<K>): void => {

0 commit comments

Comments
 (0)