Skip to content

fix(material-experimental/mdc-slider): fix change events on slider in… #22286

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<K extends 'change'|'input'> {

/** 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<K, Subject<Event>>();

/** Stores the event handlers that emit the events that occur on the global document. */
private _handlers = new Map<K, ((event: Event) => 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<K>): 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<Event>());
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(() => {
Copy link
Member

@crisbeto crisbeto Mar 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning a subscription means that we can't use operators on the same stream. You can check out src\cdk\scrolling\scroll-dispatcher.ts for an example of how this can be done using a custom observable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for this case, returning a subscription is sufficient. I think if we reach a point where we need to start using operators on the stream it would make sense to extend the functionality this way, but for the current use case and for the foreseeable future, this should be fine

// 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);
}
}
64 changes: 57 additions & 7 deletions src/material-experimental/mdc-slider/slider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -320,6 +321,12 @@ export class MatSliderThumb implements AfterViewInit, ControlValueAccessor, OnIn
/** Event emitted every time the MatSliderThumb is focused. */
@Output() readonly _focus: EventEmitter<void> = new EventEmitter<void>();

/** Event emitted on pointer up or after left or right arrow key presses. */
@Output() readonly change: EventEmitter<Event> = new EventEmitter<Event>();

/** Event emitted on each value change that happens to the slider. */
@Output() readonly input: EventEmitter<Event> = new EventEmitter<Event>();

_disabled: boolean = false;

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -605,10 +619,11 @@ export class MatSlider extends _MatSliderMixinBase
readonly _cdr: ChangeDetectorRef,
readonly _elementRef: ElementRef<HTMLElement>,
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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand All @@ -872,11 +901,32 @@ class SliderAdapter implements MDCSliderAdapter {
}
registerInputEventHandler = <K extends EventType>
(thumbPosition: Thumb, evtType: K, handler: SpecificEventListener<K>): 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 = <K extends EventType>
(thumbPosition: Thumb, evtType: K, handler: SpecificEventListener<K>): void => {
this._delegate._getInputElement(thumbPosition).removeEventListener(evtType, handler);
if (evtType === 'change') {
this.changeSubscription.unsubscribe();
} else {
this._delegate._getInputElement(thumbPosition).removeEventListener(evtType, handler);
}
}
registerBodyEventHandler =
<K extends EventType>(evtType: K, handler: SpecificEventListener<K>): void => {
Expand Down