@squirrel-forge/ui-util
Version:
A collection of utilities, classes, functions and abstracts made for the browser and babel compatible.
371 lines (324 loc) • 11.2 kB
JavaScript
/**
* Requires
*/
import { Exception } from '../Error/Exception.js';
import { cloneObject } from '../Object/cloneObject.js';
import { isPojo } from '../Object/isPojo.js';
/**
* Event dispatcher exception
* @class
* @extends Exception
*/
class EventDispatcherException extends Exception {}
/**
* @typedef {Object} EventDispatcherInterface
* @property {Function} addEventListener - Add
* @property {Function} removeEventListener - Remove
* @property {Function} dispatchEvent - Dispatch
*/
/**
* Event dispatcher
* @abstract
* @class
*/
export class EventDispatcher {
/**
* Debug object
* @private
* @property
* @type {null|console|Object}
*/
#debug = null;
/**
* Event target
* @private
* @type {null|HTMLElement|EventDispatcher}
*/
#target = null;
/**
* Parent if no target is set
* @private
* @type {null|HTMLElement|EventDispatcher}
*/
#parent = null;
/**
* Simulated events handlers map
* @private
* @type {Object}
*/
#simulated = {};
/**
* Check for compatibility
* @param {*} obj - EventDispatcher target or parent
* @return {boolean} - Is compatible
*/
static isCompat( obj ) {
return obj.addEventListener && obj.removeEventListener && obj.dispatchEvent;
}
/**
* Constructor
* @constructor
* @param {null|HTMLElement|EventDispatcherInterface|Object} element - The target element
* @param {null|EventDispatcher|EventDispatcherInterface} parent - Parent event dispatcher, only used for simulated events
* @param {null|console} debug - Console or alike object to show debugging
*/
constructor( element = null, parent = null, debug = null ) {
// Require element or null
if ( !( element === null || this.constructor.isCompat( element ) ) ) {
throw new EventDispatcherException( 'Argument element must be null or a compatible instance' );
}
// Require parent or null
if ( !( parent === null || this.constructor.isCompat( parent ) ) ) {
throw new EventDispatcherException( 'Argument parent must be null or a compatible instance' );
}
// Debugger instance
this.#debug = debug;
// Element reference
this.#target = element;
this.#parent = parent;
// Construction info
if ( this.#debug ) {
this.#debug.log( this.constructor.name + '::constructed', this );
}
}
/**
* Debug getter
* @public
* @return {null|console|Object} - Debug reference
*/
get debug() {
return this.#debug;
}
/**
* Target getter
* @public
* @return {null|HTMLElement|EventDispatcher|EventDispatcherInterface} - Target reference
*/
get target() {
return this.#target;
}
/**
* Parent getter
* @public
* @return {null|HTMLElement|EventDispatcher|EventDispatcherInterface} - Parent reference
*/
get parent() {
return this.#parent;
}
/**
* Override parent instance
* @public
* @param {null|HTMLElement|EventDispatcher|EventDispatcherInterface} parent - Parent reference
* @return {void}
*/
overrideParent( parent ) {
this.#parent = parent;
}
/**
* Check if simulated
* @public
* @return {boolean} - True if no target element is set
*/
get isSimulated() {
return this.#target === null;
}
/**
* Get event data
* @private
* @param {null|Object} data - Data object
* @return {Object} - Updated data object
*/
#parseEventData( data ) {
data = data || { target : this };
if ( !data.target ) data.target = this;
if ( !data.current ) data.current = this;
return data;
}
/**
* Run simulated event
* @private
* @param {string} name - Event name
* @param {CustomEvent} event - Custom event
* @param {Object} data - Event data
* @return {void}
*/
#runSimulated( name, event, data ) {
if ( this.#simulated[ name ] && this.#simulated[ name ].length ) {
// Clone handlers at runtime, any listeners bound from within running listeners
// will not run until the next event dispatch
const handlers = [ ...this.#simulated[ name ] ];
for ( let i = 0; i < handlers.length; i++ ) {
// Skip any handlers that are unbound at runtime if they haven't run yet
// This allows for unbind within running event listeners
if ( !this.#simulated[ name ].includes( handlers[ i ] ) ) continue;
// Run handler
try {
handlers[ i ].apply( this, [ event ] );
} catch ( e ) {
window.console.error( new EventDispatcherException( 'Simulated event error', e ) );
}
// Remove handler if required
if ( handlers[ i ] && handlers[ i ].__EventDispatcherOnce === true ) {
this.#removeSimulatedListener( name, handlers[ i ] );
}
// Break out of execution chain
// event.stopImmediatePropagation() was called
if ( event.cancelBubble ) {
if ( this.#debug ) {
this.#debug.log( this.constructor.name + '::dispatchEvent simulated [ ' + name + ' ] broke after', i );
}
break;
}
}
}
// Dispatch to parent
if ( this.#parent && event.bubbles && !event.cancelBubble ) {
// Notify bubble
if ( this.#debug ) {
this.#debug.log( this.constructor.name + '::dispatchEvent bubble [ ' + name + ' ] to', this.#parent );
}
// Bubble event to parent
if ( this.#parent instanceof EventDispatcher ) {
const cloned = cloneObject( data );
cloned.current = this.#parent;
this.#parent.dispatchEvent( name, cloned );
} else {
this.#parent.dispatchEvent( event );
}
}
}
/**
* Dispatch event
* @public
* @param {string} name - Event name
* @param {null|object} detail - Event data
* @param {boolean} bubbles - Allow event to bubble
* @param {boolean} cancelable - Allow event to be cancelled
* @return {boolean} - False if cancelled, true otherwise
*/
dispatchEvent( name, detail = null, bubbles = true, cancelable = false ) {
detail = this.#parseEventData( detail );
// Debug info
if ( this.#debug ) {
this.#debug.groupCollapsed( this.constructor.name + '::dispatchEvent [ ' + name + ' ]' );
this.#debug.log( 'element >', this.#target || this );
this.#debug.log( 'data >', detail );
this.#debug.groupEnd();
}
// Create event
const event = new CustomEvent( name, { bubbles, cancelable, detail } );
// Simulated event
if ( this.#target === null ) {
this.#runSimulated( name, event, detail );
} else {
// Actual event
this.#target.dispatchEvent( event );
}
return !event.defaultPrevented;
}
/**
* Add simulated listener
* @private
* @param {string} name - Event name
* @param {Function} callback - Event callback
* @param {boolean|Object} useCaptureOptions - Capture style or options Object
* @return {void}
*/
#addSimulatedListener( name, callback, useCaptureOptions ) {
if ( !this.#simulated[ name ] ) {
this.#simulated[ name ] = [];
}
this.#simulated[ name ].push( callback );
// Support for the once option
if ( isPojo( useCaptureOptions ) && useCaptureOptions.once === true ) {
this.#simulated[ name ][ this.#simulated[ name ].length - 1 ].__EventDispatcherOnce = true;
}
}
/**
* Check name for existing handlers
* @public
* @param {string} name - Event name
* @return {boolean} - True if event has listeners
*/
hasSimulated( name ) {
return this.#simulated[ name ] && this.#simulated[ name ].length;
}
/**
* Register event listener
* @public
* @param {string} name - Event name
* @param {Function} callback - Callback to register for event
* @param {boolean|Object} useCaptureOptions - Capture style or options Object
* @return {void}
*/
addEventListener( name, callback, useCaptureOptions = false ) {
if ( !isPojo( useCaptureOptions ) ) {
useCaptureOptions = {
once : false,
capture : useCaptureOptions,
passive : false,
};
}
if ( typeof callback !== 'function' ) {
throw new EventDispatcherException( 'Argument callback for event "' + name + '" must be a function' );
}
// Simulated event
if ( this.#target === null ) {
this.#addSimulatedListener( name, callback, useCaptureOptions );
} else {
// Actual event
this.#target.addEventListener( name, callback, useCaptureOptions );
}
// Notify register
if ( this.#debug ) {
this.#debug.groupCollapsed( this.constructor.name + '::addEventListener [ ' + name + ' ]' );
this.#debug.log( 'element >', this.#target );
this.#debug.log( 'callback >', callback );
this.#debug.groupEnd();
}
}
/**
* Remove simulated listener
* @private
* @param {string} name - Event name
* @param {Function} callback - Event callback
* @return {void}
*/
#removeSimulatedListener( name, callback ) {
if ( this.#simulated[ name ] ) {
for ( let i = 0; i < this.#simulated[ name ].length; i++ ) {
if ( this.#simulated[ name ][ i ] === callback ) {
this.#simulated[ name ].splice( i, 1 );
}
}
}
}
/**
* Remove event listener
* @public
* @param {string} name - Event name
* @param {function} callback - Callback to deregister from event
* @param {boolean|Object} useCaptureOptions - Capture style or options Object
* @return {void}
*/
removeEventListener( name, callback, useCaptureOptions = false ) {
// Simulated event
if ( this.#target === null ) {
this.#removeSimulatedListener( name, callback );
} else {
// Actual event
this.#target.removeEventListener( name, callback, useCaptureOptions );
}
}
/**
* Register an array of event listeners
* @public
* @param {Array<Array>} events - Array of addEventListener argument arrays
* @return {void}
*/
addEventList( events ) {
for ( let i = 0; i < events.length; i++ ) {
this.addEventListener( ...events[ i ] );
}
}
}