ypsilon-event-handler
Version:
A production-ready event handling system for web applications with memory leak prevention, and method chaining support
584 lines (496 loc) • 20.9 kB
JavaScript
/**
* YpsilonEventHandler - Ultra lightweight, extendable event handling system
*/
class YpsilonEventHandler {
constructor(eventMapping = {}, aliases = {}, config = {}) {
this.eventMapping = eventMapping;
this.aliases = aliases;
this.config = {
enableStats: false,
methods: null,
enableGlobalFallback: false,
methodsFirst: false,
...config
};
this.enableStats = this.config.enableStats;
this.methods = this.config.methods || {};
this.enableGlobalFallback = this.config.enableGlobalFallback;
this.methodsFirst = this.config.methodsFirst;
this.eventListeners = new Map();
this.elementHandlers = new WeakMap();
this.eventHandlerMap = new Map();
this.passiveSupported = false;
this.throttleTimers = new Map();
this.debounceTimers = new Map();
this.userHasInteracted = false;
this.passiveEvents = [
'scroll', 'touchstart', 'touchmove', 'touchend', 'touchcancel',
'wheel', 'mousewheel', 'pointermove', 'pointerenter', 'pointerleave',
'resize', 'orientationchange', 'load', 'beforeunload', 'unload'
];
this.init();
}
init() {
this.detectPassiveSupport();
this.registerEvents();
}
handleEvent(event) {
this.checkUserInteraction(event);
const handlers = this.eventHandlerMap.get(event.type);
if (!handlers || handlers.length === 0) return;
// Find the closest handler by checking which element is closest to event.target
let closestHandler = null;
let closestDistance = Infinity;
for (const handlerInfo of handlers) {
let isContained = false;
let distance = 0;
if (handlerInfo.element === document || handlerInfo.element === window) {
// Document and window always "contain" any target
isContained = true;
distance = 1000; // Give them low priority (high distance)
} else if (handlerInfo.element.contains && handlerInfo.element.contains(event.target)) {
// Regular DOM element containment
isContained = true;
let current = event.target;
while (current && current !== handlerInfo.element) {
distance++;
current = current.parentElement;
}
}
if (isContained && distance < closestDistance) {
closestDistance = distance;
closestHandler = handlerInfo;
}
}
if (closestHandler) {
const handler = this.resolveHandler(closestHandler.handler, event.type);
if (handler) {
handler.call(this, event, event.target);
}
}
}
dispatch(type, detail = null, target = document) {
target.dispatchEvent(new CustomEvent(type, {
bubbles: true,
detail
}));
return this;
}
detectPassiveSupport() {
try {
const opts = Object.defineProperty({}, 'passive', {
get: () => {
this.passiveSupported = true;
return true;
}
});
window.addEventListener('testPassive', null, opts);
window.removeEventListener('testPassive', null, opts);
} catch (e) {
this.passiveSupported = false;
}
}
hasUserInteracted() {
return this.userHasInteracted;
}
/**
* Get comprehensive statistics about the event handler instance
* @returns {object|null} - Statistics object with various metrics, or null if stats disabled
*/
getStats() {
if (!this.enableStats) {
return null;
}
const configs = Array.from(this.eventListeners.values());
const events = configs.flatMap(config => config.events);
// Count event types
const eventTypes = {};
events.forEach(event => {
eventTypes[event.type] = (eventTypes[event.type] || 0) + 1;
});
// Count unique elements
const uniqueElements = new Set(configs.map(config => config.element));
return {
totalListeners: this.eventListeners.size,
totalElements: uniqueElements.size,
totalEventTypes: Object.keys(eventTypes).length,
eventTypes,
userHasInteracted: this.userHasInteracted,
activeTimers: {
throttle: this.throttleTimers.size,
debounce: this.debounceTimers.size
}
};
}
checkUserInteraction(event) {
// Only count real user interactions, not passive events
if (!this.userHasInteracted && !this.passiveEvents.includes(event.type)) {
this.userHasInteracted = true;
}
}
/**
* Resolve method name using event-type specific aliases
* @param {string} methodName - Original method name
* @param {string} eventType - Event type for scoped alias lookup
* @returns {string} - Resolved method name (or original if no alias)
*/
resolveMethodName(methodName, eventType) {
const eventAliases = this.aliases[eventType];
return (eventAliases && eventAliases[methodName]) || methodName;
}
/**
* Enhanced handler resolution with methods object and global fallback support
* @param {string} handlerName - Handler method name to resolve
* @param {string} eventType - Event type for scoped alias lookup
* @returns {Function|null} - Resolved handler function or null if not found
*/
resolveHandler(handlerName, eventType) {
// First resolve any aliases
const resolvedName = this.resolveMethodName(handlerName, eventType);
// Define resolution order based on methodsFirst setting
const resolutionOrder = this.methodsFirst
? ['methods', 'class', 'global']
: ['class', 'methods', 'global'];
for (const source of resolutionOrder) {
let handler = null;
switch (source) {
case 'class':
if (typeof this[resolvedName] === 'function') {
handler = this[resolvedName];
}
break;
case 'methods':
if (this.methods && typeof this.methods[resolvedName] === 'function') {
handler = this.methods[resolvedName];
}
break;
case 'global':
if (this.enableGlobalFallback && typeof window !== 'undefined' && typeof window[resolvedName] === 'function') {
handler = window[resolvedName];
}
break;
}
if (handler) {
return handler;
}
}
return null;
}
/**
* Dynamically add a single event listener to existing instance
* @param {string} target - CSS selector for target element
* @param {string|object} eventConfig - Event type string or config object
* @returns {boolean} - True if added, false if already exists
*/
addEvent(target, eventConfig) {
// Normalize the event config
const normalizedConfig = typeof eventConfig === 'string'
? { type: eventConfig }
: eventConfig;
// Check if event already exists to prevent duplicates
if (this.hasEvent(target, normalizedConfig.type)) {
return false; // Already exists
}
// Add to eventMapping
if (!this.eventMapping[target]) {
this.eventMapping[target] = [];
}
this.eventMapping[target].push(normalizedConfig);
// Register the new event
this.registerSingleEvent(target, normalizedConfig);
return true; // Successfully added
}
/**
* Remove a specific event listener from target
* @param {string} target - CSS selector for target element
* @param {string} eventType - Event type to remove
* @returns {boolean} - True if removed, false if not found
*/
removeEvent(target, eventType) {
// Check if mapping exists
if (!this.eventMapping[target]) {
return false;
}
// Find and remove from eventMapping
const initialLength = this.eventMapping[target].length;
this.eventMapping[target] = this.eventMapping[target].filter(config => {
const configType = typeof config === 'string' ? config : config.type;
return configType !== eventType;
});
// If nothing was removed, return false
if (this.eventMapping[target].length === initialLength) {
return false;
}
// Clean up empty target
if (this.eventMapping[target].length === 0) {
delete this.eventMapping[target];
}
// Remove from internal tracking
this.unregisterSingleEvent(target, eventType);
return true; // Successfully removed
}
/**
* Check if an event is currently registered
* @param {string} target - CSS selector for target element
* @param {string} eventType - Event type to check
* @returns {boolean} - True if event exists
*/
hasEvent(target, eventType) {
if (!this.eventMapping[target]) {
return false;
}
return this.eventMapping[target].some(config => {
const configType = typeof config === 'string' ? config : config.type;
return configType === eventType;
});
}
/**
* Create a wrapped handler with throttle/debounce if needed
* @private
*/
createWrappedHandler(eventConfig, key, eventType) {
let handler = this;
// Apply throttle/debounce if specified
if (typeof eventConfig === 'object') {
const originalHandleEvent = this.handleEvent;
if (eventConfig.throttle) {
handler = {
handleEvent: this.throttle(originalHandleEvent, eventConfig.throttle, `${key}-${eventType}-throttle`)
};
} else if (eventConfig.debounce) {
handler = {
handleEvent: this.debounce(originalHandleEvent, eventConfig.debounce, `${key}-${eventType}-debounce`)
};
}
}
return handler;
}
/**
* Clean up throttle/debounce timers for a specific event
* @private
*/
cleanupEventTimers(key, eventType) {
const throttleKey = `${key}-${eventType}-throttle`;
const debounceKey = `${key}-${eventType}-debounce`;
if (this.throttleTimers.has(throttleKey)) {
const timerData = this.throttleTimers.get(throttleKey);
if (timerData.timeout) clearTimeout(timerData.timeout);
if (timerData.trailingTimeout) clearTimeout(timerData.trailingTimeout);
this.throttleTimers.delete(throttleKey);
}
if (this.debounceTimers.has(debounceKey)) {
clearTimeout(this.debounceTimers.get(debounceKey));
this.debounceTimers.delete(debounceKey);
}
}
/**
* Register a single event listener (internal helper)
* @private
*/
registerEventListener(element, eventConfig, key, selector) {
const eventType = typeof eventConfig === 'string' ? eventConfig : eventConfig.type;
const options = this.getEventOptions(eventConfig);
const handler = this.createWrappedHandler(eventConfig, key, eventType);
// Add the event listener
element.addEventListener(eventType, handler, options);
// Initialize tracking structures
if (!this.eventListeners.has(key)) {
this.eventListeners.set(key, { element, events: [] });
}
if (!this.elementHandlers.has(element)) {
this.elementHandlers.set(element, []);
}
// Get handler method name
const handlerMethod = typeof eventConfig === 'object' && eventConfig.handler
? eventConfig.handler
: `handle${eventType.charAt(0).toUpperCase() + eventType.slice(1)}`;
// Validate handler exists using enhanced resolution
const validatedHandler = this.resolveHandler(handlerMethod, eventType);
if (!validatedHandler) {
const resolvedName = this.resolveMethodName(handlerMethod, eventType);
const aliasMsg = resolvedName !== handlerMethod ? ` (resolved from alias '${handlerMethod}')` : '';
console.warn(`YpsilonEventHandler: Handler method '${resolvedName}'${aliasMsg} not found for event '${eventType}' on element '${selector}' (checked class, methods object, and global scope)`);
}
// Store tracking info
this.eventListeners.get(key).events.push({ type: eventType, handler, options });
this.elementHandlers.get(element).push({ type: eventType, handler, options });
// Update handler mapping for multi-handler support
if (!this.eventHandlerMap.has(eventType)) {
this.eventHandlerMap.set(eventType, []);
}
this.eventHandlerMap.get(eventType).push({
element: element,
handler: handlerMethod,
config: eventConfig
});
}
/**
* Register a single event (internal helper)
* @private
*/
registerSingleEvent(selector, eventConfig) {
const eventType = typeof eventConfig === 'string' ? eventConfig : eventConfig.type;
const elements = this.getElements(selector);
if (elements.length === 0) return;
elements.forEach((element, index) => {
const key = `${selector}_${eventType}_${index}`;
// Check if this exact combination is already registered
if (this.eventListeners.has(key)) {
return; // Already registered
}
this.registerEventListener(element, eventConfig, key, selector);
});
}
/**
* Unregister a single event (internal helper)
* @private
*/
unregisterSingleEvent(selector, eventType) {
const elements = this.getElements(selector);
if (elements.length === 0) return;
elements.forEach((element, index) => {
const key = `${selector}_${eventType}_${index}`;
// Remove from event listeners tracking
const listenerConfig = this.eventListeners.get(key);
if (listenerConfig) {
// Remove the actual event listener using the stored handler
const eventData = listenerConfig.events.find(e => e.type === eventType);
if (eventData) {
element.removeEventListener(eventType, eventData.handler, eventData.options);
}
}
// Clean up tracking
this.eventListeners.delete(key);
// Clean up WeakMap entries
const elementEvents = this.elementHandlers.get(element);
if (elementEvents) {
const filteredEvents = elementEvents.filter(e => e.type !== eventType);
if (filteredEvents.length === 0) {
this.elementHandlers.delete(element);
} else {
this.elementHandlers.set(element, filteredEvents);
}
}
// Clean up any timers for this event
this.cleanupEventTimers(key, eventType);
});
// Remove from handler mapping - clean all elements for this selector/eventType
const elementsToClean = this.getElements(selector);
elementsToClean.forEach(element => {
const handlers = this.eventHandlerMap.get(eventType);
if (handlers) {
const filteredHandlers = handlers.filter(h => h.element !== element);
if (filteredHandlers.length === 0) {
this.eventHandlerMap.delete(eventType);
} else {
this.eventHandlerMap.set(eventType, filteredHandlers);
}
}
});
}
getEventOptions(eventConfig) {
const shouldBePassive = this.passiveSupported && this.passiveEvents.includes(eventConfig.type || eventConfig);
if (typeof eventConfig === 'string') {
return shouldBePassive ? { passive: true } : false;
}
const options = eventConfig.options || {};
// Apply passive if event type supports it, unless explicitly disabled with passive: false
if (shouldBePassive && options.passive !== false) {
options.passive = true;
}
return Object.keys(options).length > 0 ? options : false;
}
getElements(selector) {
if (typeof selector === 'string') {
if (selector === 'document') return [document];
if (selector === 'window') return [window];
return Array.from(document.querySelectorAll(selector));
}
return selector instanceof Element ? [selector] : [];
}
debounce(fn, delay, key) {
return (...args) => {
if (this.debounceTimers.has(key)) {
clearTimeout(this.debounceTimers.get(key));
}
this.debounceTimers.set(key, setTimeout(() => {
fn.apply(this, args);
this.debounceTimers.delete(key);
}, delay));
};
}
throttle(fn, delay, key) {
return (...args) => {
const timerData = this.throttleTimers.get(key);
if (!timerData) {
// Leading edge: execute immediately
fn.apply(this, args);
this.throttleTimers.set(key, {
timeout: setTimeout(() => {
this.throttleTimers.delete(key);
}, delay),
lastArgs: args
});
} else {
// Update arguments for trailing edge
timerData.lastArgs = args;
// Clear existing trailing timeout and set new one
if (timerData.trailingTimeout) {
clearTimeout(timerData.trailingTimeout);
}
timerData.trailingTimeout = setTimeout(() => {
// Trailing edge: execute with latest arguments
fn.apply(this, timerData.lastArgs);
this.throttleTimers.delete(key);
}, delay);
}
};
}
registerEvents() {
Object.entries(this.eventMapping).forEach(([key, config]) => {
const isSimplified = Array.isArray(config);
const elementSelector = isSimplified ? key : config.element;
const events = isSimplified ? config : config.events;
const elements = this.getElements(elementSelector);
if (elements.length === 0) return;
elements.forEach((element, index) => {
events.forEach(eventConfig => {
const eventType = typeof eventConfig === 'string' ? eventConfig : eventConfig.type;
const eventKey = `${elementSelector}_${eventType}_${index}`;
// Use the shared registration logic
this.registerEventListener(element, eventConfig, eventKey, elementSelector);
});
});
});
return this;
}
destroy() {
this.eventListeners.forEach((config) => {
config.events.forEach(({ type, handler, options }) => {
config.element.removeEventListener(type, handler, options);
});
});
this.eventListeners.clear();
this.eventHandlerMap.clear();
// Clean up throttle timers
this.throttleTimers.forEach((timerData) => {
if (timerData.timeout) clearTimeout(timerData.timeout);
if (timerData.trailingTimeout) clearTimeout(timerData.trailingTimeout);
});
this.throttleTimers.clear();
// Clean up debounce timers
this.debounceTimers.forEach((timerId) => clearTimeout(timerId));
this.debounceTimers.clear();
return this;
}
}
// Export for ES6 modules and CommonJS
if (typeof module !== 'undefined' && module.exports) {
// CommonJS
module.exports = { YpsilonEventHandler };
// Also support default export for compatibility
module.exports.default = YpsilonEventHandler;
} else if (typeof window !== 'undefined') {
// Browser global
window.YpsilonEventHandler = YpsilonEventHandler;
}