Skip to content

Commit c647ba5

Browse files
committed
fix(material-experimental/mdc-slider): fix change and input events on the mdc slider
* use #fromEvent to simplify the global change and input listener * go back to dispatching real events instead of using Angular's event emitter system * fix how the global change and input listener is used in the slider adapter * Rename fake event indicator boolean * Simplified change & input event logic to make things more readable
1 parent a7b7b8d commit c647ba5

File tree

3 files changed

+131
-108
lines changed

3 files changed

+131
-108
lines changed

src/material-experimental/mdc-slider/global-change-and-input-listener.ts

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,64 +7,61 @@
77
*/
88

99
import {DOCUMENT} from '@angular/common';
10-
import {Inject, Injectable, NgZone} from '@angular/core';
10+
import {Inject, Injectable, NgZone, OnDestroy} from '@angular/core';
1111
import {SpecificEventListener} from '@material/base';
12-
import {Subject, Subscription} from 'rxjs';
13-
import {finalize} from 'rxjs/operators';
12+
import {fromEvent, Observable, Subject, Subscription} from 'rxjs';
13+
import {finalize, share, takeUntil} from 'rxjs/operators';
1414

1515
/**
1616
* Handles listening for all change and input events that occur on the document.
1717
*
1818
* 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.
19+
* events that occur on the document. Since listening for these events can be expensive, we use
20+
* #fromEvent which will lazily attach a listener when the first subscription is made and remove the
21+
* listener once the last observer unsubscribes.
2222
*/
2323
@Injectable({providedIn: 'root'})
24-
export class GlobalChangeAndInputListener<K extends 'change'|'input'> {
24+
export class GlobalChangeAndInputListener<K extends 'change'|'input'> implements OnDestroy {
2525

2626
/** The injected document if available or fallback to the global document reference. */
2727
private _document: Document;
2828

2929
/** Stores the subjects that emit the events that occur on the global document. */
30-
private _subjects = new Map<K, Subject<Event>>();
30+
private _observables = new Map<K, Observable<Event>>();
3131

32-
/** Stores the event handlers that emit the events that occur on the global document. */
33-
private _handlers = new Map<K, ((event: Event) => void)>();
32+
/** The notifier that triggers the global event observables to stop emitting and complete. */
33+
private _destroyed = new Subject();
3434

3535
constructor(@Inject(DOCUMENT) document: any, private _ngZone: NgZone) {
3636
this._document = document;
3737
}
3838

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-
};
39+
ngOnDestroy() {
40+
this._destroyed.next();
41+
this._destroyed.complete();
42+
this._observables.clear();
4443
}
4544

4645
/** Returns a subscription to global change or input events. */
4746
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-
});
47+
// If this is the first time we are listening to this event, create the observable for it.
48+
if (!this._observables.has(type)) {
49+
this._observables.set(type, this._createGlobalEventObservable(type));
5650
}
5751

58-
const subject = this._subjects.get(type)!;
59-
const handler = this._handlers.get(type)!;
52+
return this._ngZone.runOutsideAngular(() =>
53+
this._observables.get(type)!.subscribe((event: Event) =>
54+
this._ngZone.run(() => callback(event))
55+
)
56+
);
57+
}
6058

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);
59+
/** Creates an observable that emits all events of the given type. */
60+
private _createGlobalEventObservable(type: K) {
61+
return fromEvent(this._document, type, {capture: true, passive: true}).pipe(
62+
takeUntil(this._destroyed),
63+
finalize(() => this._observables.delete(type)),
64+
share(),
65+
);
6966
}
7067
}

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

Lines changed: 18 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
109
import {Platform} from '@angular/cdk/platform';
1110
import {
12-
dispatchFakeEvent,
1311
dispatchMouseEvent,
1412
dispatchPointerEvent,
1513
dispatchTouchEvent,
@@ -524,11 +522,12 @@ describe('MDC-based MatSlider' , () => {
524522
});
525523

526524
describe('slider with set step', () => {
525+
let fixture: ComponentFixture<SliderWithStep>;
527526
let sliderInstance: MatSlider;
528527
let inputInstance: MatSliderThumb;
529528

530529
beforeEach(waitForAsync(() => {
531-
const fixture = createComponent(SliderWithStep);
530+
fixture = createComponent(SliderWithStep);
532531
fixture.detectChanges();
533532
const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider));
534533
sliderInstance = sliderDebugElement.componentInstance;
@@ -553,28 +552,20 @@ describe('MDC-based MatSlider' , () => {
553552
});
554553

555554
it('should truncate long decimal values when using a decimal step', () => {
556-
// TODO(wagnermaciel): Uncomment this test once b/182504575 is resolved.
557-
// sliderInstance.step = 0.1;
558-
// slideToValue(sliderInstance, 33.3333, Thumb.END, platform.IOS);
559-
// expect(inputInstance.value).toBe(33);
560-
});
561-
562-
it('should truncate long decimal values when using a decimal step and the arrow keys', () => {
563555
sliderInstance.step = 0.1;
564-
changeValueUsingArrowKeys(sliderInstance, RIGHT_ARROW, Thumb.END);
565-
changeValueUsingArrowKeys(sliderInstance, RIGHT_ARROW, Thumb.END);
566-
changeValueUsingArrowKeys(sliderInstance, RIGHT_ARROW, Thumb.END);
567-
expect(inputInstance.value).toBe(0.3);
556+
slideToValue(sliderInstance, 66.3333, Thumb.END, platform.IOS);
557+
expect(inputInstance.value).toBe(66.3);
568558
});
569559
});
570560

571561
describe('range slider with set step', () => {
562+
let fixture: ComponentFixture<RangeSliderWithStep>;
572563
let sliderInstance: MatSlider;
573564
let startInputInstance: MatSliderThumb;
574565
let endInputInstance: MatSliderThumb;
575566

576567
beforeEach(waitForAsync(() => {
577-
const fixture = createComponent(RangeSliderWithStep);
568+
fixture = createComponent(RangeSliderWithStep);
578569
fixture.detectChanges();
579570
const sliderDebugElement = fixture.debugElement.query(By.directive(MatSlider));
580571
sliderInstance = sliderDebugElement.componentInstance;
@@ -617,33 +608,23 @@ describe('MDC-based MatSlider' , () => {
617608
});
618609

619610
it('should truncate long decimal start values when using a decimal step', () => {
620-
// TODO(wagnermaciel): Uncomment this test once b/182504575 is resolved.
621-
// sliderInstance.step = 0.1;
622-
// slideToValue(sliderInstance, 33.3333, Thumb.START, platform.IOS);
623-
// expect(startInputInstance.value).toBe(33);
611+
sliderInstance.step = 0.1;
612+
slideToValue(sliderInstance, 66.3333, Thumb.START, platform.IOS);
613+
expect(startInputInstance.value).toBe(66.3);
624614
});
625615

626616
it('should truncate long decimal end values when using a decimal step', () => {
627-
// TODO(wagnermaciel): Uncomment this test once b/182504575 is resolved.
628-
// sliderInstance.step = 0.1;
629-
// slideToValue(sliderInstance, 66.6666, Thumb.END, platform.IOS);
630-
// expect(endInputInstance.value).toBe(66);
631-
});
632-
633-
it('should truncate long decimal start values when using a decimal step arrow keys', () => {
634617
sliderInstance.step = 0.1;
635-
changeValueUsingArrowKeys(sliderInstance, RIGHT_ARROW, Thumb.START);
636-
changeValueUsingArrowKeys(sliderInstance, RIGHT_ARROW, Thumb.START);
637-
changeValueUsingArrowKeys(sliderInstance, RIGHT_ARROW, Thumb.START);
638-
expect(startInputInstance.value).toBe(0.3);
639-
});
618+
slideToValue(sliderInstance, 66.3333, Thumb.END, platform.IOS);
619+
expect(endInputInstance.value).toBe(66.3);
640620

641-
it('should truncate long decimal end values when using a decimal step arrow keys', () => {
642-
sliderInstance.step = 0.1;
643-
changeValueUsingArrowKeys(sliderInstance, LEFT_ARROW, Thumb.END);
644-
changeValueUsingArrowKeys(sliderInstance, LEFT_ARROW, Thumb.END);
645-
changeValueUsingArrowKeys(sliderInstance, LEFT_ARROW, Thumb.END);
646-
expect(endInputInstance.value).toBe(99.7);
621+
// NOTE(wagnermaciel): Different browsers treat the clientX dispatched by us differently.
622+
// Below is an example of a case that should work but because Firefox rounds the clientX
623+
// down, the clientX that gets dispatched (1695.998...) is not the same clientX that the MDC
624+
// Foundation receives (1695). This means the test will pass on chromium but fail on Firefox.
625+
//
626+
// slideToValue(sliderInstance, 66.66, Thumb.END, platform.IOS);
627+
// expect(endInputInstance.value).toBe(66.7);
647628
});
648629
});
649630

@@ -979,22 +960,6 @@ function slideToValue(slider: MatSlider, value: number, thumbPosition: Thumb, is
979960
dispatchPointerOrTouchEvent(sliderElement, PointerEventType.POINTER_UP, endX, endY, isIOS);
980961
}
981962

982-
/**
983-
* Mimics changing the slider value using arrow keys.
984-
*
985-
* Dispatching keydown events on inputs do not trigger value changes. Thus, to mimic this behavior,
986-
* we manually change the slider inputs value and then dispatch a change event (which is what the
987-
* MDC Foundation is listening for & how it handles these updates).
988-
*/
989-
function changeValueUsingArrowKeys(slider: MatSlider, arrow: number, thumbPosition: Thumb) {
990-
const input = slider._getInput(thumbPosition);
991-
const value = arrow === RIGHT_ARROW
992-
? input.value + slider.step
993-
: input.value - slider.step;
994-
input._hostElement.value = value.toString();
995-
dispatchFakeEvent(input._hostElement, 'change');
996-
}
997-
998963
/** Dispatch a pointerdown or pointerup event if supported, otherwise dispatch the touch event. */
999964
function dispatchPointerOrTouchEvent(
1000965
node: Node, type: PointerEventType, x: number, y: number, isIOS: boolean) {

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

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -383,9 +383,8 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnIn
383383

384384
_emitFakeEvent(type: 'change'|'input') {
385385
const event = new Event(type) as any;
386-
event.isFake = true;
387-
const emitter = type === 'change' ? this.change : this.input;
388-
emitter.emit(event);
386+
event._matIsHandled = true;
387+
this._hostElement.dispatchEvent(event);
389388
}
390389

391390
/**
@@ -772,10 +771,85 @@ export class MatSlider extends _MatSliderMixinBase
772771
/** The MDCSliderAdapter implementation. */
773772
class SliderAdapter implements MDCSliderAdapter {
774773

775-
/** The global change listener subscription used to handle change events on the slider inputs. */
776-
changeSubscription: Subscription;
774+
/** The global event listener subscription used to handle events on the slider inputs. */
775+
private _globalEventSubscriptions = new Subscription();
777776

778-
constructor(private readonly _delegate: MatSlider) {}
777+
/** The MDC Foundations handler function for start input change events. */
778+
private _startInputChangeEventHandler: SpecificEventListener<EventType>;
779+
780+
/** The MDC Foundations handler function for end input change events. */
781+
private _endInputChangeEventHandler: SpecificEventListener<EventType>;
782+
783+
constructor(private readonly _delegate: MatSlider) {
784+
this._globalEventSubscriptions.add(this._subscribeToSliderInputEvents('change'));
785+
this._globalEventSubscriptions.add(this._subscribeToSliderInputEvents('input'));
786+
}
787+
788+
/**
789+
* Handles "change" and "input" events on the slider inputs.
790+
*
791+
* Exposes a callback to allow the MDC Foundations "change" event handler to be called for "real"
792+
* events.
793+
*
794+
* ** IMPORTANT NOTE **
795+
*
796+
* We block all "real" change and input events and emit fake events from #emitChangeEvent and
797+
* #emitInputEvent, instead. We do this because interacting with the MDC slider won't trigger all
798+
* of the correct change and input events, but it will call #emitChangeEvent and #emitInputEvent
799+
* at the correct times. This allows users to listen for these events directly on the slider
800+
* input as they would with a native range input.
801+
*/
802+
private _subscribeToSliderInputEvents(type: 'change'|'input') {
803+
return this._delegate._globalChangeAndInputListener.listen(type, (event: Event) => {
804+
const thumbPosition = this._getInputThumbPosition(event.target);
805+
806+
// Do nothing if the event isn't from a thumb input.
807+
if (thumbPosition === null) { return; }
808+
809+
// Do nothing if the event is "fake".
810+
if ((event as any)._matIsHandled) { return ; }
811+
812+
// Prevent "real" events from reaching end users.
813+
event.stopImmediatePropagation();
814+
815+
// Relay "real" change events to the MDC Foundation.
816+
if (type === 'change') {
817+
this._callChangeEventHandler(event, thumbPosition);
818+
}
819+
});
820+
}
821+
822+
/** Calls the MDC Foundations change event handler for the specified thumb position. */
823+
private _callChangeEventHandler(event: Event, thumbPosition: Thumb) {
824+
if (thumbPosition === Thumb.START) {
825+
this._startInputChangeEventHandler(event);
826+
} else {
827+
this._endInputChangeEventHandler(event);
828+
}
829+
}
830+
831+
/** Save the event handler so it can be used in our global change event listener subscription. */
832+
private _saveChangeEventHandler(thumbPosition: Thumb, handler: SpecificEventListener<EventType>) {
833+
if (thumbPosition === Thumb.START) {
834+
this._startInputChangeEventHandler = handler;
835+
} else {
836+
this._endInputChangeEventHandler = handler;
837+
}
838+
}
839+
840+
/**
841+
* Returns the thumb position of the given event target.
842+
* Returns null if the given event target does not correspond to a slider thumb input.
843+
*/
844+
private _getInputThumbPosition(target: EventTarget | null): Thumb | null {
845+
if (target === this._delegate._getInputElement(Thumb.END)) {
846+
return Thumb.END;
847+
}
848+
if (this._delegate._isRange() && target === this._delegate._getInputElement(Thumb.START)) {
849+
return Thumb.START;
850+
}
851+
return null;
852+
}
779853

780854
// We manually assign functions instead of using prototype methods because
781855
// MDC clobbers the values otherwise.
@@ -901,29 +975,16 @@ class SliderAdapter implements MDCSliderAdapter {
901975
}
902976
registerInputEventHandler = <K extends EventType>
903977
(thumbPosition: Thumb, evtType: K, handler: SpecificEventListener<K>): void => {
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-
});
978+
if (evtType === 'change') {
979+
this._saveChangeEventHandler(thumbPosition, handler as SpecificEventListener<EventType>);
919980
} else {
920981
this._delegate._getInputElement(thumbPosition).addEventListener(evtType, handler);
921982
}
922983
}
923984
deregisterInputEventHandler = <K extends EventType>
924985
(thumbPosition: Thumb, evtType: K, handler: SpecificEventListener<K>): void => {
925986
if (evtType === 'change') {
926-
this.changeSubscription.unsubscribe();
987+
this._globalEventSubscriptions.unsubscribe();
927988
} else {
928989
this._delegate._getInputElement(thumbPosition).removeEventListener(evtType, handler);
929990
}

0 commit comments

Comments
 (0)