Skip to content

Commit

Permalink
fix(ripple): Listen for up events at document level (#1800)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `registerDocumentInteractionHandler ` and `deregisterDocumentInteractionHandler` APIs have been added to the ripple adapter.
  • Loading branch information
kfranqueiro authored Jan 5, 2018
1 parent 8a835ab commit e9f02ed
Show file tree
Hide file tree
Showing 10 changed files with 256 additions and 182 deletions.
2 changes: 2 additions & 0 deletions packages/mdc-ripple/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ ripple to. The adapter API is as follows:
| `removeClass(className: string) => void` | Removes a class from the ripple surface |
| `registerInteractionHandler(evtType: string, handler: EventListener) => void` | Registers an event handler that's invoked when the ripple is interacted with using type `evtType`. Essentially equivalent to `HTMLElement.prototype.addEventListener`. |
| `deregisterInteractionHandler(evtType: string, handler: EventListener) => void` | Unregisters an event handler that's invoked when the ripple is interacted with using type `evtType`. Essentially equivalent to `HTMLElement.prototype.removeEventListener`. |
| `registerDocumentInteractionHandler(evtType: string, handler: EventListener) => void` | Registers an event handler that's invoked when the documentElement is interacted with using type `evtType` |
| `deregisterDocumentInteractionHandler(evtType: string, handler: EventListener) => void` | Unregisters an event handler that's invoked when the documentElement is interacted with using type `evtType` |
| `registerResizeHandler(handler: Function) => void` | Registers a handler to be called when the surface (or its viewport) resizes. Our default implementation adds the handler as a listener to the window's `resize()` event. |
| `deregisterResizeHandler(handler: Function) => void` | Unregisters a handler to be called when the surface (or its viewport) resizes. Our default implementation removes the handler as a listener to the window's `resize()` event. |
| `updateCssVariable(varName: string, value: (string or null)) => void` | Programmatically sets the css variable `varName` on the surface to the value specified. |
Expand Down
12 changes: 12 additions & 0 deletions packages/mdc-ripple/adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ class MDCRippleAdapter {
*/
deregisterInteractionHandler(evtType, handler) {}

/**
* @param {string} evtType
* @param {!Function} handler
*/
registerDocumentInteractionHandler(evtType, handler) {}

/**
* @param {string} evtType
* @param {!Function} handler
*/
deregisterDocumentInteractionHandler(evtType, handler) {}

/**
* @param {!Function} handler
*/
Expand Down
223 changes: 121 additions & 102 deletions packages/mdc-ripple/foundation.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {getNormalizedEventCoords} from './util';
* hasDeactivationUXRun: (boolean|undefined),
* wasActivatedByPointer: (boolean|undefined),
* wasElementMadeActive: (boolean|undefined),
* activationStartTime: (number|undefined),
* activationEvent: Event,
* isProgrammatic: (boolean|undefined)
* }}
Expand Down Expand Up @@ -61,15 +60,11 @@ let ListenersType;
*/
let PointType;

/**
* @enum {string}
*/
const DEACTIVATION_ACTIVATION_PAIRS = {
mouseup: 'mousedown',
pointerup: 'pointerdown',
touchend: 'touchstart',
keyup: 'keydown',
};
// Activation events registered on the root element of each instance for activation
const ACTIVATION_EVENT_TYPES = ['touchstart', 'pointerdown', 'mousedown', 'keydown'];

// Deactivation events registered on documentElement when a pointer-related down event occurs
const POINTER_DEACTIVATION_EVENT_TYPES = ['touchend', 'pointerup', 'mouseup'];

/**
* @extends {MDCFoundation<!MDCRippleAdapter>}
Expand Down Expand Up @@ -97,6 +92,8 @@ class MDCRippleFoundation extends MDCFoundation {
removeClass: (/* className: string */) => {},
registerInteractionHandler: (/* evtType: string, handler: EventListener */) => {},
deregisterInteractionHandler: (/* evtType: string, handler: EventListener */) => {},
registerDocumentInteractionHandler: (/* evtType: string, handler: EventListener */) => {},
deregisterDocumentInteractionHandler: (/* evtType: string, handler: EventListener */) => {},
registerResizeHandler: (/* handler: EventListener */) => {},
deregisterResizeHandler: (/* handler: EventListener */) => {},
updateCssVariable: (/* varName: string, value: string */) => {},
Expand Down Expand Up @@ -126,26 +123,21 @@ class MDCRippleFoundation extends MDCFoundation {
/** @private {number} */
this.maxRadius_ = 0;

/** @private {!Array<{ListenerInfoType}>} */
this.listenerInfos_ = [
{activate: 'touchstart', deactivate: 'touchend'},
{activate: 'pointerdown', deactivate: 'pointerup'},
{activate: 'mousedown', deactivate: 'mouseup'},
{activate: 'keydown', deactivate: 'keyup'},
{focus: 'focus', blur: 'blur'},
];

/** @private {!ListenersType} */
this.listeners_ = {
activate: (e) => this.activate_(e),
deactivate: (e) => this.deactivate_(e),
focus: () => requestAnimationFrame(
() => this.adapter_.addClass(MDCRippleFoundation.cssClasses.BG_FOCUSED)
),
blur: () => requestAnimationFrame(
() => this.adapter_.removeClass(MDCRippleFoundation.cssClasses.BG_FOCUSED)
),
};
/** @private {function(!Event)} */
this.activateHandler_ = (e) => this.activate_(e);

/** @private {function(!Event)} */
this.deactivateHandler_ = (e) => this.deactivate_(e);

/** @private {function(?Event=)} */
this.focusHandler_ = () => requestAnimationFrame(
() => this.adapter_.addClass(MDCRippleFoundation.cssClasses.BG_FOCUSED)
);

/** @private {function(?Event=)} */
this.blurHandler_ = () => requestAnimationFrame(
() => this.adapter_.removeClass(MDCRippleFoundation.cssClasses.BG_FOCUSED)
);

/** @private {!Function} */
this.resizeHandler_ = () => this.layout();
Expand Down Expand Up @@ -173,6 +165,9 @@ class MDCRippleFoundation extends MDCFoundation {
this.activationAnimationHasEnded_ = true;
this.runDeactivationUXLogicIfReady_();
};

/** @private {?Event} */
this.previousActivationEvent_ = null;
}

/**
Expand All @@ -196,7 +191,6 @@ class MDCRippleFoundation extends MDCFoundation {
hasDeactivationUXRun: false,
wasActivatedByPointer: false,
wasElementMadeActive: false,
activationStartTime: 0,
activationEvent: null,
isProgrammatic: false,
};
Expand All @@ -206,7 +200,7 @@ class MDCRippleFoundation extends MDCFoundation {
if (!this.isSupported_()) {
return;
}
this.addEventListeners_();
this.registerRootHandlers_();

const {ROOT, UNBOUNDED} = MDCRippleFoundation.cssClasses;
requestAnimationFrame(() => {
Expand All @@ -218,18 +212,75 @@ class MDCRippleFoundation extends MDCFoundation {
});
}

destroy() {
if (!this.isSupported_()) {
return;
}
this.deregisterRootHandlers_();
this.deregisterDeactivationHandlers_();

const {ROOT, UNBOUNDED} = MDCRippleFoundation.cssClasses;
requestAnimationFrame(() => {
this.adapter_.removeClass(ROOT);
this.adapter_.removeClass(UNBOUNDED);
this.removeCssVars_();
});
}

/** @private */
addEventListeners_() {
this.listenerInfos_.forEach((info) => {
Object.keys(info).forEach((k) => {
this.adapter_.registerInteractionHandler(info[k], this.listeners_[k]);
});
registerRootHandlers_() {
ACTIVATION_EVENT_TYPES.forEach((type) => {
this.adapter_.registerInteractionHandler(type, this.activateHandler_);
});
this.adapter_.registerInteractionHandler('focus', this.focusHandler_);
this.adapter_.registerInteractionHandler('blur', this.blurHandler_);
this.adapter_.registerResizeHandler(this.resizeHandler_);
}

/**
* @param {Event} e
* @param {!Event} e
* @private
*/
registerDeactivationHandlers_(e) {
if (e.type === 'keydown') {
this.adapter_.registerInteractionHandler('keyup', this.deactivateHandler_);
} else {
POINTER_DEACTIVATION_EVENT_TYPES.forEach((type) => {
this.adapter_.registerDocumentInteractionHandler(type, this.deactivateHandler_);
});
}
}

/** @private */
deregisterRootHandlers_() {
ACTIVATION_EVENT_TYPES.forEach((type) => {
this.adapter_.deregisterInteractionHandler(type, this.activateHandler_);
});
this.adapter_.deregisterInteractionHandler('focus', this.focusHandler_);
this.adapter_.deregisterInteractionHandler('blur', this.blurHandler_);
this.adapter_.deregisterResizeHandler(this.resizeHandler_);
}

/** @private */
deregisterDeactivationHandlers_() {
this.adapter_.deregisterInteractionHandler('keyup', this.deactivateHandler_);
POINTER_DEACTIVATION_EVENT_TYPES.forEach((type) => {
this.adapter_.deregisterDocumentInteractionHandler(type, this.deactivateHandler_);
});
}

/** @private */
removeCssVars_() {
const {strings} = MDCRippleFoundation;
Object.keys(strings).forEach((k) => {
if (k.indexOf('VAR_') === 0) {
this.adapter_.updateCssVariable(strings[k], null);
}
});
}

/**
* @param {?Event} e
* @private
*/
activate_(e) {
Expand All @@ -242,13 +293,24 @@ class MDCRippleFoundation extends MDCFoundation {
return;
}

// Avoid reacting to follow-on events fired by touch device after an already-processed user interaction
const previousActivationEvent = this.previousActivationEvent_;
const isSameInteraction = previousActivationEvent && e && previousActivationEvent.type !== e.type &&
previousActivationEvent.clientX === e.clientX && previousActivationEvent.clientY === e.clientY;
if (isSameInteraction) {
return;
}

activationState.isActivated = true;
activationState.isProgrammatic = e === null;
activationState.activationEvent = e;
activationState.wasActivatedByPointer = activationState.isProgrammatic ? false : (
e.type === 'mousedown' || e.type === 'touchstart' || e.type === 'pointerdown'
);
activationState.activationStartTime = Date.now();

if (e) {
this.registerDeactivationHandlers_(e);
}

requestAnimationFrame(() => {
// This needs to be wrapped in an rAF call b/c web browsers
Expand Down Expand Up @@ -361,48 +423,39 @@ class MDCRippleFoundation extends MDCFoundation {
this.adapter_.computeBoundingRect();
}

resetActivationState_() {
this.previousActivationEvent_ = this.activationState_.activationEvent;
this.activationState_ = this.defaultActivationState_();
// Touch devices may fire additional events for the same interaction within a short time.
// Store the previous event until it's safe to assume that subsequent events are for new interactions.
setTimeout(() => this.previousActivationEvent_ = null, 100);
}

/**
* @param {Event} e
* @param {?Event} e
* @private
*/
deactivate_(e) {
const {activationState_: activationState} = this;
const activationState = this.activationState_;
// This can happen in scenarios such as when you have a keyup event that blurs the element.
if (!activationState.isActivated) {
return;
}
// Programmatic deactivation.

const state = /** @type {!ActivationStateType} */ (Object.assign({}, activationState));

if (activationState.isProgrammatic) {
const evtObject = null;
const state = /** @type {!ActivationStateType} */ (Object.assign({}, activationState));
requestAnimationFrame(() => this.animateDeactivation_(evtObject, state));
this.activationState_ = this.defaultActivationState_();
return;
}

const actualActivationType = DEACTIVATION_ACTIVATION_PAIRS[e.type];
const expectedActivationType = activationState.activationEvent.type;
// NOTE: Pointer events are tricky - https://patrickhlauke.github.io/touch/tests/results/
// Essentially, what we need to do here is decouple the deactivation UX from the actual
// deactivation state itself. This way, touch/pointer events in sequence do not trample one
// another.
const needsDeactivationUX = actualActivationType === expectedActivationType;
let needsActualDeactivation = needsDeactivationUX;
if (activationState.wasActivatedByPointer) {
needsActualDeactivation = e.type === 'mouseup';
}

const state = /** @type {!ActivationStateType} */ (Object.assign({}, activationState));
requestAnimationFrame(() => {
if (needsDeactivationUX) {
this.resetActivationState_();
} else {
this.deregisterDeactivationHandlers_();
requestAnimationFrame(() => {
this.activationState_.hasDeactivationUXRun = true;
this.animateDeactivation_(e, state);
}

if (needsActualDeactivation) {
this.activationState_ = this.defaultActivationState_();
}
});
this.resetActivationState_();
});
}
}

/**
Expand All @@ -423,40 +476,6 @@ class MDCRippleFoundation extends MDCFoundation {
}
}

destroy() {
if (!this.isSupported_()) {
return;
}
this.removeEventListeners_();

const {ROOT, UNBOUNDED} = MDCRippleFoundation.cssClasses;
requestAnimationFrame(() => {
this.adapter_.removeClass(ROOT);
this.adapter_.removeClass(UNBOUNDED);
this.removeCssVars_();
});
}

/** @private */
removeEventListeners_() {
this.listenerInfos_.forEach((info) => {
Object.keys(info).forEach((k) => {
this.adapter_.deregisterInteractionHandler(info[k], this.listeners_[k]);
});
});
this.adapter_.deregisterResizeHandler(this.resizeHandler_);
}

/** @private */
removeCssVars_() {
const {strings} = MDCRippleFoundation;
Object.keys(strings).forEach((k) => {
if (k.indexOf('VAR_') === 0) {
this.adapter_.updateCssVariable(strings[k], null);
}
});
}

layout() {
if (this.layoutFrame_) {
cancelAnimationFrame(this.layoutFrame_);
Expand Down
4 changes: 4 additions & 0 deletions packages/mdc-ripple/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ class MDCRipple extends MDCComponent {
instance.root_.addEventListener(evtType, handler, util.applyPassive()),
deregisterInteractionHandler: (evtType, handler) =>
instance.root_.removeEventListener(evtType, handler, util.applyPassive()),
registerDocumentInteractionHandler: (evtType, handler) =>
document.documentElement.addEventListener(evtType, handler, util.applyPassive()),
deregisterDocumentInteractionHandler: (evtType, handler) =>
document.documentElement.removeEventListener(evtType, handler, util.applyPassive()),
registerResizeHandler: (handler) => window.addEventListener('resize', handler),
deregisterResizeHandler: (handler) => window.removeEventListener('resize', handler),
updateCssVariable: (varName, value) => instance.root_.style.setProperty(varName, value),
Expand Down
Loading

0 comments on commit e9f02ed

Please sign in to comment.