ypsilon-event-handler
Version:
A production-ready event handling system for web applications with memory leak prevention, and method chaining support
207 lines (178 loc) • 7.98 kB
JavaScript
/**
* YpsilonEventHandler - Minimal, extendable event handling system
*/
class YpsilonEventHandler {
constructor(eventMapping = {}) {
this.eventMapping = eventMapping;
this.eventListeners = new Map();
this.elementHandlers = new WeakMap();
this.passiveSupported = false;
this.throttleTimers = new Map();
this.debounceTimers = new Map();
this.init();
}
init() {
this.detectPassiveSupport();
this.handleEvent = (event) => {
const handlerName = `handle${event.type.charAt(0).toUpperCase() + event.type.slice(1)}`;
if (typeof this[handlerName] === 'function') {
this[handlerName](event, event.target);
}
};
this.registerEvents();
}
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;
}
}
getEventOptions(eventConfig) {
const passiveEvents = ['scroll', 'touchstart', 'touchmove', 'wheel', 'mousewheel'];
const shouldBePassive = this.passiveSupported && passiveEvents.includes(eventConfig.type || eventConfig);
if (typeof eventConfig === 'string') {
return shouldBePassive ? { passive: true } : false;
}
const options = eventConfig.options || {};
if (shouldBePassive && options.passive !== false) {
options.passive = true;
}
return Object.keys(options).length > 0 ? options : false;
}
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);
}
};
}
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));
};
}
registerEvents() {
Object.entries(this.eventMapping).forEach(([key, config]) => {
// Check if this is the new simplified syntax (selector as key)
const isSimplified = Array.isArray(config);
const elementSelector = isSimplified ? key : config.element;
const events = isSimplified ? config : config.events;
const element = this.getElement(elementSelector);
if (!element) return;
this.eventListeners.set(key, { element, events: [] });
// Initialize WeakMap entry for this element
if (!this.elementHandlers.has(element)) {
this.elementHandlers.set(element, []);
}
events.forEach(eventConfig => {
const eventType = typeof eventConfig === 'string' ? eventConfig : eventConfig.type;
const customHandler = typeof eventConfig === 'object' ? eventConfig.handler : null;
const options = this.getEventOptions(eventConfig);
let handler = this;
// Create custom handler if specified
if (customHandler && typeof this[customHandler] === 'function') {
handler = {
handleEvent: (event) => this[customHandler](event, event.target)
};
}
// Apply throttle/debounce if specified
if (typeof eventConfig === 'object') {
if (eventConfig.throttle) {
const throttleKey = `${key}-${eventType}-throttle`;
const originalHandleEvent = handler.handleEvent ?
handler.handleEvent :
(event) => this.handleEvent(event);
handler = {
handleEvent: this.throttle(originalHandleEvent, eventConfig.throttle, throttleKey)
};
} else if (eventConfig.debounce) {
const debounceKey = `${key}-${eventType}-debounce`;
const originalHandleEvent = handler.handleEvent ?
handler.handleEvent :
(event) => this.handleEvent(event);
handler = {
handleEvent: this.debounce(originalHandleEvent, eventConfig.debounce, debounceKey)
};
}
}
element.addEventListener(eventType, handler, options);
// Store in both regular Map (for cleanup) and WeakMap (for memory safety)
this.eventListeners.get(key).events.push({ type: eventType, handler, options });
this.elementHandlers.get(element).push({ type: eventType, handler, options });
});
});
return this;
}
getElement(selector) {
if (typeof selector === 'string') {
if (selector === 'document') return document;
if (selector === 'window') return window;
return document.querySelector(selector);
}
return selector instanceof Element ? selector : null;
}
destroy() {
this.eventListeners.forEach((config) => {
config.events.forEach(({ type, handler, options }) => {
config.element.removeEventListener(type, handler, options);
});
});
this.eventListeners.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;
}
// Note: ES6 export syntax not compatible with browser script tags
// Use import maps or bundlers for ES6 module support