toolshed-8.x-1.x-dev/assets/EventListener.es6.js
assets/EventListener.es6.js
(({ Toolshed: ts, debounce }) => {
/**
* Creates a queue of callable functions that can get managed and triggered
* together. The main purpose of this for queuing event listeners or
* registering a series of callbacks.
*
* CallList are orderable, and can be used to insert listeners around a
* specified reference point (before or after another callable).
*/
class CallList {
/**
* Ensure that a listener is a valid handler for the event used by this
* EventListener. This test is for checking the listener before adding it
* to the list of active listeners for this event.
*
* @param {Array|function} callable
* The object to test if it is valid for handling this event.
*
* @return {bool}
* Boolean to indicate if this listener is valid for handling this event.
* _true_ IFF this listener can be added and used with this event object.
*/
static isCallable(callable) {
let obj = null;
let func = callable;
if (callable instanceof Array) {
[obj, func] = callable;
}
return (typeof func === 'function') && (!obj || obj instanceof Object);
}
/**
* Execute a callable item, with the parameters passed to the underlying
* function or method. A callable is either a function or an array
* (containing an object to use as the "this" and a method to execute).
*
* @param {function|Array} callable
* A callable is either a function that can be called directly or is an
* array that contains an object (used for "this") and a method to call.
* @param {*[]} ...args
* Additional arguments passed with the called item.
*
* @return {*|null}
* The result of the callabable
*/
static callItem(callable, ...args) {
if (callable instanceof Array) {
const [obj, func] = callable;
return func.apply(obj, args);
}
if (typeof callable === 'function') {
return callable(...args);
}
throw new Error('Unable to execute callable method.');
}
/**
* Create a new instance of a callable list of items.
*/
constructor() {
this.list = [];
}
/**
* Get the current size of the callable list.
*
* @return {int}
* The current size of the callables list array.
*/
size() {
return this.list.length;
}
/**
* Make a call to a list of callables.
*
* @param {Event} param
* argument object to pass to each of the callables as they get
* called respectively.
*/
call(param, ...args) {
this.list.forEach((cb) => CallList.callItem(cb, param, ...args));
}
/**
* If there is a valid atPos, place the callable at this position,
* otherwise, just add it to the end of the list. This allows some
* flexibility to place callabbles at the start of the list, or
* before other callables.
*
* @param {Array|function} callable
* A callable object to add to the list.
* @param {int} atPos
* Index to add the callable at. This allows callables to be run in
* a different order than they maybe registered in.
*/
add(callable, atPos) {
if (!CallList.isCallable(callable)) {
throw new Error('Trying to add new callback, but it is not a valid callable.');
}
// Ensure that all existing references to this event are removed.
// Prevents the event from being called more than once unintentionally.
this.remove(callable);
if (atPos !== null && atPos >= 0) this.list.splice(atPos - 1, 0, callable);
else this.list.push(callable);
}
/**
* Remove the specified callable from the list of callables.
*
* @param {Array|function} callable
* A listener object that requests to get removed.
*/
remove(callable) {
let pos = this.indexOf(callable);
while (pos >= 0) {
this.list.splice(pos, 1);
pos = this.indexOf(callable, pos);
}
}
/**
* Look for an a matching listener in the objects listener registry.
* Listeners can be stored as either function references, or arrays. If
* an array, the first item in the array is the object calling context,
* and the second parameter is function to call.
*
* Matches can be found by function, array (object and function) or just
* by matching the object contexts.
*
* @param {Array|Object|function} needle
* The matching listener to locate in the listeners array. If the param
* is an array, look for the matching object and function. If an object
* is passed in, find the first occurance of the object as the object
* context in the arrays. If a function, just search for the function.
* @param {int} start
* The starting position in the listener array to search for the callback.
*
* @return {int}
* The index where the matching listener was found. If a matching listener
* was not found, return -1 to indicate no match is available.
*/
indexOf(needle, start = 0) {
if (typeof needle === 'function') {
// For functions, matching is direct and straightforward.
return this.list.indexOf(needle, start);
}
const [obj, func] = (needle instanceof Array) ? needle : [null, needle];
for (; start < this.list.length; ++start) {
const item = this.list[start];
const [context, callback] = (item instanceof Array) ? item : [null, item];
if (obj === context && (!func || func === callback)) {
return start;
}
}
return -1;
}
}
/**
* Attach DOM and element events, which allow global registration and utilize
* a CallList for event calling and management. These event listeners
* have options for handling debounce, callables (@see CallList) and
* auto registration (only adding the event listener to the DOM when listener
* is added).
*
* Toolshed.EventListener.{eventName} namespace. Some of the events in that
* namespace may have customized event callback. An example of this is defined
* in ./screen-events.es6.js which are used by the Toolshed.Dock.
*/
ts.EventListener = class {
/**
* Constructor for creating of event listeners.
*
* @param {DOMElement} elem
* DOM element that will be the target of the event.
* @param {string} eventName
* The event.
* @param {null|object} options
* method:
* Name of the method to call on all listeners (special cases). Will call
* the default "on[this.eventName]" method if left blank.
* useCapture:
* Use capture instead of bubbling for event propagation.
* passive:
* Event handlers will not call preventDefault() which can enable browser
* optimatization that no longer need to wait for all handlers to complete
* before triggering other events like scrolling.
* debounce:
* Determine if the event only triggers using debounce handling. This means
* that events will only fire off after a short delay.
*
* If null or FALSE, no debounce will be used, and the event registered
* fires off as soon as the event is raised.
*
* If TRUE then use the default debounce delay. If an integer, than use the
* value as the delay in milliseconds.
*/
constructor(elem, eventName, options) {
options = options || {}; // options can be left blank.
this.elem = elem;
this.event = eventName;
this.autoListen = options.autoListen || false;
this.listeners = new CallList();
// Check and properly organize the event options to be used later.
if (options.debounce) {
this.debounce = (typeof options.debounce === 'boolean') ? 100 : options.debounce;
}
// Allow for addEventListener options as described here
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
// I am also employing the https://github.com/WICG/EventListenerOptions
// as a polyfill, but support will not be available for IE8 and earlier.
this.eventOpts = {
capture: options.capture || false,
passive: options.passive || false,
};
}
/**
* Trigger the event for all the registered listeners. Custom
* EventListeners are most likely to override this function in order
* to create implement special functionality, triggered by events.
*
* @param {Object} event
* The event object that was generated and passed to the event handler.
*/
_run(event, ...args) {
this.listners.call(event, ...args);
}
/**
* Trigger the event manaully.
*
* @param {Event|null} event
* Event data to use with this event.
*
* @return {Drupal.Toolshed.EventListener}
* Return this instance of this EventListener for the purpose of chaining.
*/
trigger(event, ...args) {
this._run(event || new Event(this.event), args);
return this;
}
/**
* Register the event, and keep track of the callback so it can be removed
* later if we need to disable / remove the listener at a later time.
*
* @return {Drupal.Toolshed.EventListener}
* Return this instance of this EventListener for the purpose of chaining.
*/
listen() {
if (!this.callback && (!this.autoListen || this.listeners.size())) {
this.callback = (this.debounce && this.debounce > 0 && debounce)
? debounce(this._run.bind(this), this.debounce) : this._run.bind(this);
this.elem.addEventListener(this.event, this.callback, this.eventOpts);
}
return this;
}
/**
* Stop listening for this event, and unregister from any event listeners.
*
* @return {Drupal.Toolshed.EventListener}
* Return this instance of this EventListener for the purpose of chaining.
*/
ignore() {
if (this.callback) {
this.elem.removeEventListener(this.event, this.callback);
delete this.callback;
}
return this;
}
/**
* If there is a valid atPos, place the listener at this position,
* otherwise, just add it to the end of the list. This allows some
* flexibility to place listeners at the start of the list, or
* before other listeners.
*
* @param {Object} listener
* A listener object that contains the a method 'on' + [this.eventName].
* @param {int} atPos
* Index to add the listener at. This allows listeners to be run in
* a different order than they maybe registered in.
*
* @return {Drupal.Toolshed.EventListener}
* Return this instance of this EventListener for the purpose of chaining.
*/
add(listener, atPos) {
this.listeners.add(listener, atPos);
if (this.autoListen) this.listen();
return this;
}
/**
* Add a new listener before an existing listener already in the list.
* If [before] is null, then insert at the start of the list.
*
* @param {Array|function} listener
* A listener object that contains the a method 'on' + [this.eventName].
* @param {Array|function} before
* Listener object that is used to position the new listener.
*
* @return {Drupal.Toolshed.EventListener}
* Return this instance of this EventListener for the purpose of chaining.
*/
addBefore(listener, before) {
const pos = (before) ? this.listeners.indexOf(before) : 0;
return this.add(listener, pos < 0 ? 0 : pos);
}
/**
* Add a new listener after an existing listener already in the list.
* If [after] is null, then insert at the end of the list.
*
* @param {Array|function} listener
* A listener object that represents a callable.
* @param {Array|function} after
* Listener object that is used to position the new listener.
*
* @return {Drupal.Toolshed.EventListener}
* Return this instance of this EventListener for the purpose of chaining.
*/
addAfter(listener, after) {
let pos = null;
if (after) {
pos = this.listeners.indexOf(after);
pos = pos >= 0 ? pos + 1 : -1;
}
return this.add(listener, pos);
}
/**
* Remove the specified listener from the list of event listeners.
* This assume there should only be one entry pert callback.
*
* @param {Array|function} listener
* A listener object that requests to get removed.
*
* @return {Drupal.Toolshed.EventListener}
* Return this instance of this EventListener for the purpose of chaining.
*/
remove(listener) {
this.listeners.remove(listener);
// If there are no listeners and the autoListen option is on, turn off
// listening. This prevents the event from being called for no reason.
if (this.autoListen && !this.listeners.size()) this.ignore();
return this;
}
/**
* Clean-up events and data.
*/
destroy() {
this.ignore();
}
};
/**
* Event listener for media query listeners.
*/
ts.MediaQueryListener = class {
/**
* Constructs a new Media Query listener instance.
*
* @param {Object[]} breakpoints
* An array of breakpoints in the order they should be checked. Each
* breakpoint object is expected to have an `mq`, `inverted` and `event`
* property which help determine what event to call when a Media Query
* listener triggers.
*/
constructor(breakpoints) {
this.mode = null;
this.curBp = null;
this.bps = new Map();
this.aliases = new Map();
breakpoints.forEach((bp) => {
const mql = window.matchMedia(bp.mediaQuery);
if (bp.event && !this.aliases.has(bp.event)) {
this.aliases.set(bp.event, {
on: new CallList(),
off: new CallList(),
});
}
this.bps.set(bp.id, {
query: mql,
mode: bp.event || null,
inverted: bp.inverted || false,
});
});
}
/**
* Alter the current breakpoint, and trigger the related events.
*
* @param {string} newBp
* The ID of the breakpoint to trigger.
*/
_changeMode(newBp) {
let newMode = null;
// If the mode changed, trigger the appropriate action.
if (newBp === this.curBp) return;
if (this.curBp) {
const offList = this.bps.get(this.curBp);
if (offList && offList.off) offList.off.call(this.curBp, 'off');
}
this.curBp = newBp;
if (newBp) {
const onList = this.bps.get(newBp);
if (onList) {
newMode = onList.mode;
if (onList.on) onList.on.call(this.curBp, 'on');
}
}
if (newMode !== this.mode) {
if (this.mode) {
const offMode = this.aliases.get(this.mode);
if (offMode) offMode.off.call(this.mode, 'off');
}
this.mode = newMode;
if (newMode) {
const onMode = this.aliases.get(newMode);
if (onMode) onMode.on.call(this.mode, 'on');
}
}
}
/**
* Check the registered breakpoints in order to see which one is active.
*
* @return {string|null}
* The query mapped event if a matching breakpoint is found, otherwise
* return null to mean no event.
*/
checkBreakpoints() {
const bps = Array.from(this.bps.entries());
for (let i = 0; i < bps.length; ++i) {
const [id, bp] = bps[i];
if (!bp.query.matches !== !bp.inverted) {
return id;
}
}
return null;
}
/**
* Add a new listener to a breakpoint or mode.
*
* @param {string} bpId
* The ID of the breakpoint to watch for, or the media query event to
* attach the listener callable to.
* @param {[type]} listener
* The callable callback to get called when the breakpoint matches the
* action specified (either on or off).
* @param {string} action
* Either 'on' or 'off' to indicated of the callback should be called
* when the media query is active or deactivated respectively.
*
* @return {Object}
* The current instance of the MediaQueryListener, and the "this" object.
*/
add(bpId, listener, action = 'on') {
if (action === 'on' || action === 'off') {
let target;
let curState;
if (this.aliases.has(bpId)) {
target = this.aliases.get(bpId);
curState = this.mode;
}
else if (this.bps.has(bpId)) {
target = this.bps.get(bpId);
curState = this.curBp;
if (!target[action]) target[action] = new CallList();
}
else {
throw new Error(`Error adding ${bpId} with action ${action}`);
}
target[action].add(listener);
// Trigger 'on' event when listener is added if current state matches
// screen sizes targeted by added listener.
if (curState === bpId && action === 'on') {
CallList.callItem(listener, bpId, 'on');
}
// Trigger 'off' event when listener is added if current state does not
// match screen sizes targeted by added listener.
else if (curState !== bpId && action === 'off') {
CallList.callItem(listener, bpId, 'off');
}
}
return this;
}
/**
* Listen for changes of the registered breakpoints.
*/
listen() {
let current;
this.bps.forEach((bp, id) => {
if (!bp.callback) {
bp.callback = ((mql) => {
const mode = (!mql.matches !== !bp.inverted) ? id : this.checkBreakpoints();
this._changeMode(mode);
});
bp.query.addListener(bp.callback);
}
// Stop updating this after the first breakpoint to trigger.
if (!current && (!bp.query.matches !== !bp.inverted)) {
current = id;
}
});
this._changeMode(current);
}
/**
* Unregister all media query listeners, and ignore all breakpoint events.
*/
ignore() {
this.bps.forEach((id, bp) => {
if (bp.callback) {
bp.query.removeListener(bp.callback);
delete bp.callback;
}
});
}
};
})(Drupal);
