UNPKG

ypsilon-event-handler

Version:

Supercharged multi-handler event system with closest-match DOM resolution and QuantumType docs. The most advanced event delegation architecture available.

1,033 lines (871 loc) 39.1 kB
"use strict"; /** * YpsilonEventHandler - Revolutionary multi-handler event system with closest-match DOM resolution * The most advanced event delegation architecture available - features no other library offers out of the box */ class YpsilonEventHandler { constructor(eventMapping = {}, aliases = {}, config = {}) { this.config = { enableStats: false, methods: null, enableGlobalFallback: false, methodsFirst: false, passiveEvents: null, abortController: false, autoTargetResolution: false, targetResolutionEvents: null, enableConfigValidation: true, enableHandlerValidation: true, ...config }; this.eventMapping = eventMapping; this.aliases = aliases; 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.throttleTimers = new Map(); this.debounceTimers = new Map(); this.userHasInteracted = false; this.passiveSupported = false; this.passiveEvents = this.config.passiveEvents || [ 'scroll', 'touchstart', 'touchmove', 'touchend', 'touchcancel', 'wheel', 'mousewheel', 'pointermove', 'pointerenter', 'pointerleave', 'resize', 'orientationchange', 'load', 'beforeunload', 'unload' ]; this.handlerPrefix = this.config.handlerPrefix !== undefined ? this.config.handlerPrefix : 'handle'; this.abortController = this.config.abortController ? new AbortController() : null; this.autoTargetResolution = this.config.autoTargetResolution; this.targetResolutionEvents = this.config.targetResolutionEvents || ['click', 'touchstart', 'touchend', 'mousedown', 'mouseup']; // DOM Distance Cache for performance optimization this.distanceCache = new Map(); this.enableDistanceCache = this.config.enableDistanceCache !== false; // Default: enabled // Configurable actionable target patterns this.actionableConfig = { attributes: this.config.actionableAttributes || ['data-action'], classes: this.config.actionableClasses || ['actionable'], tags: this.config.actionableTags || ['BUTTON', 'A'], enabled: this.config.enableActionableTargets !== false // Default: enabled }; if (this.config.enableConfigValidation) { this.validateConfiguration(); } this.detectPassiveSupport(); this.registerEvents(); } handleEvent(event) { const handlers = this.eventHandlerMap.get(event.type); if (!handlers || handlers.length === 0) return; this.checkUserInteraction(event); // Find the closest handler by checking which element is closest to event.target let closestHandler = null; let closestDistance = Infinity; for (const handlerInfo of handlers) { const distance = this.calculateDistanceWithCache(event.target, handlerInfo.element); if (distance !== Infinity && distance < closestDistance) { closestDistance = distance; closestHandler = handlerInfo; } } if (closestHandler) { const handler = this.resolveHandler(closestHandler.handler, event.type); if (handler) { // Use smart target resolution for problematic events if enabled let resolvedTarget = event.target; if (this.autoTargetResolution && this.targetResolutionEvents.includes(event.type)) { const actionableTarget = this.findActionableTarget(event.target, closestHandler.element); if (actionableTarget) { resolvedTarget = actionableTarget; } else if (this.actionableConfig.enabled) { return; // If no actionable target found, block event } // If actionable config is disabled, keep original target (backward compatibility) } // Find the actual closest matching element for this event target const actualClosestElement = this.findClosest(event.target, closestHandler.selector); handler.call(this, event, resolvedTarget, actualClosestElement); event.stopPropagation(); return; } } } on(type, handler, target) { this.addEvent(target || 'document', { type, handler }); return this; } subscribe(type, handler, target) { this.on(type, handler, target); return this; } emit(type, detail, target) { if (typeof target === 'string') { target = document.querySelector(target); } this.dispatch(type, detail, target || document); return this; } hasUserInteracted() { return this.userHasInteracted; } resetUserInteracted() { this.userHasInteracted = false; } checkUserInteraction(event) { if (!this.userHasInteracted && !this.passiveEvents.includes(event.type)) { this.userHasInteracted = true; } } /** * Calculate DOM distance with caching for performance optimization * @private */ calculateDistanceWithCache(target, container) { if (!this.enableDistanceCache) { return this.calculateDOMDistance(target, container); } // Create cache key based on both elements const cacheKey = `${this.getElementKey(target)}-${this.getElementKey(container)}`; if (this.distanceCache.has(cacheKey)) { return this.distanceCache.get(cacheKey); } const distance = this.calculateDOMDistance(target, container); // Cache the result for future lookups this.distanceCache.set(cacheKey, distance); return distance; } /** * Calculate actual DOM distance between elements * @private */ calculateDOMDistance(target, container) { if (container === document || container === window) { return 1000; // Low priority for document/window } if (!container.contains || !container.contains(target)) { return Infinity; // Not contained } let distance = 0; let current = target; while (current && current !== container) { current = current.parentNode; distance++; } return distance; } /** * Clear distance cache (useful for dynamic DOM changes) */ clearDistanceCache() { this.distanceCache.clear(); } /** * Validate configuration and provide helpful error messages * @private */ validateConfiguration() { if (!this.eventMapping || typeof this.eventMapping !== 'object') { throw new Error('YpsilonEventHandler: eventMapping must be a non-null object'); } for (const [selector, config] of Object.entries(this.eventMapping)) { this.validateSelectorConfig(selector, config); } this.validateActionableConfig(); } /** * Validate individual selector configuration * @private */ validateSelectorConfig(selector, config) { if (!selector || typeof selector !== 'string') { throw new Error(`YpsilonEventHandler: Selector must be a non-empty string, got: ${typeof selector}`); } if (!Array.isArray(config)) { throw new Error(`YpsilonEventHandler: Config for selector "${selector}" must be an array, got: ${typeof config}`); } if (config.length === 0) { throw new Error(`YpsilonEventHandler: Config for selector "${selector}" cannot be empty`); } config.forEach((eventConfig, index) => { this.validateEventConfig(selector, eventConfig, index); }); } /** * Validate individual event configuration * @private */ validateEventConfig(selector, eventConfig, index) { if (typeof eventConfig === 'string') { if (!eventConfig.trim()) { throw new Error(`YpsilonEventHandler: Event type for selector "${selector}" at index ${index} cannot be empty`); } return; // Simple string config is valid } if (!eventConfig || typeof eventConfig !== 'object') { throw new Error(`YpsilonEventHandler: Event config for selector "${selector}" at index ${index} must be string or object, got: ${typeof eventConfig}`); } if (!eventConfig.type || typeof eventConfig.type !== 'string') { throw new Error(`YpsilonEventHandler: Event config for selector "${selector}" at index ${index} must have a valid "type" property`); } // Validate throttle/debounce values if (eventConfig.throttle !== undefined) { if (typeof eventConfig.throttle !== 'number' || eventConfig.throttle <= 0) { throw new Error(`YpsilonEventHandler: Throttle value for selector "${selector}" at index ${index} must be a positive number, got: ${eventConfig.throttle}`); } } if (eventConfig.debounce !== undefined) { if (typeof eventConfig.debounce !== 'number' || eventConfig.debounce <= 0) { throw new Error(`YpsilonEventHandler: Debounce value for selector "${selector}" at index ${index} must be a positive number, got: ${eventConfig.debounce}`); } } // Can't have both throttle and debounce if (eventConfig.throttle && eventConfig.debounce) { throw new Error(`YpsilonEventHandler: Event config for selector "${selector}" at index ${index} cannot have both throttle and debounce`); } // Validate handler name if provided if (eventConfig.handler !== undefined && typeof eventConfig.handler !== 'string') { throw new Error(`YpsilonEventHandler: Handler for selector "${selector}" at index ${index} must be a string, got: ${typeof eventConfig.handler}`); } } /** * Validate actionable target configuration * @private */ validateActionableConfig() { if (this.config.actionableAttributes !== undefined) { if (!Array.isArray(this.config.actionableAttributes)) { throw new Error('YpsilonEventHandler: actionableAttributes must be an array of strings'); } for (const attr of this.config.actionableAttributes) { if (typeof attr !== 'string' || !attr.trim()) { throw new Error('YpsilonEventHandler: actionableAttributes must contain non-empty strings'); } } } if (this.config.actionableClasses !== undefined) { if (!Array.isArray(this.config.actionableClasses)) { throw new Error('YpsilonEventHandler: actionableClasses must be an array of strings'); } for (const className of this.config.actionableClasses) { if (typeof className !== 'string' || !className.trim()) { throw new Error('YpsilonEventHandler: actionableClasses must contain non-empty strings'); } } } if (this.config.actionableTags !== undefined) { if (!Array.isArray(this.config.actionableTags)) { throw new Error('YpsilonEventHandler: actionableTags must be an array of strings'); } for (const tagName of this.config.actionableTags) { if (typeof tagName !== 'string' || !tagName.trim()) { throw new Error('YpsilonEventHandler: actionableTags must contain non-empty strings'); } } } if (this.config.enableActionableTargets !== undefined && typeof this.config.enableActionableTargets !== 'boolean') { throw new Error('YpsilonEventHandler: enableActionableTargets must be a boolean'); } if (this.config.handlerPrefix !== undefined && typeof this.config.handlerPrefix !== 'string') { throw new Error('YpsilonEventHandler: handlerPrefix must be a string'); } } /** * Generate unique key for DOM element (for caching) * @private */ getElementKey(element) { if (element === document) return 'document'; if (element === window) return 'window'; if (!element || !element.tagName) return 'unknown'; // Use tagName + id + class for uniqueness const tagName = element.tagName.toLowerCase(); const id = element.id ? `#${element.id}` : ''; // Handle SVG elements where className is an SVGAnimatedString object let className = ''; if (element.className) { if (typeof element.className === 'string') { className = `.${element.className.split(' ').join('.')}`; } else if (element.className.baseVal) { // SVG elements have className.baseVal className = element.className.baseVal ? `.${element.className.baseVal.split(' ').join('.')}` : ''; } } const index = element.parentNode ? Array.from(element.parentNode.children).indexOf(element) : 0; return `${tagName}${id}${className}[${index}]`; } 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] : []; } /** * Create a wrapped handler with throttle/debounce if needed * @private */ createWrappedHandler(eventConfig, key, eventType) { let handler = this; if (typeof eventConfig === 'object') { if (eventConfig.throttle) { handler = { handleEvent: this.throttle((event) => this.handleEvent(event), eventConfig.throttle, `${key}-${eventType}-throttle`) }; } else if (eventConfig.debounce) { handler = { handleEvent: this.debounce((event) => this.handleEvent(event), eventConfig.debounce, `${key}-${eventType}-debounce`) }; } } return handler; } /** * Find the actionable target by walking up the DOM tree * Solves the SVG-in-button and nested element problems * Uses configurable actionable patterns for maximum flexibility */ findActionableTarget(target, boundary) { // Return if resolution is disabled if (!this.actionableConfig.enabled) return target; let current = target; // Walk up the DOM tree until we hit the boundary element while (current && current !== boundary && current !== document) { // Check for configured actionable attributes for (const attr of this.actionableConfig.attributes) { if (current.hasAttribute(attr)) { return current; } } // Check for configured actionable classes if (current.classList) { for (const className of this.actionableConfig.classes) { if (current.classList.contains(className)) { return current; } } } // Check for configured actionable tags if (this.actionableConfig.tags.includes(current.tagName)) { return current; } current = current.parentElement; } // If we reached the boundary and it's actionable, return it if (current === boundary) { // Check configured actionable attributes on boundary for (const attr of this.actionableConfig.attributes) { if (boundary.hasAttribute(attr)) { return boundary; } } // Check configured actionable classes on boundary if (boundary.classList) { for (const className of this.actionableConfig.classes) { if (boundary.classList.contains(className)) { return boundary; } } } } return null; } /** * Cross-browser closest() implementation * @param {Element} element - Starting element * @param {string} selector - CSS selector to match * @returns {Element|null} - Closest matching ancestor or null */ findClosest(element, selector) { // Use native closest() if available (modern browsers) if (element.closest) { return element.closest(selector); } // Fallback for older browsers (IE11, old Chrome/Firefox) let current = element; // Simple selector parsing for basic cases const isIdSelector = selector.startsWith('#'); const isClassSelector = selector.startsWith('.'); const cleanSelector = selector.slice(1); // Remove # or . while (current && current !== document) { if (isIdSelector && current.id === cleanSelector) { return current; } else if (isClassSelector && current.classList && current.classList.contains(cleanSelector)) { return current; } else if (!isIdSelector && !isClassSelector && current.tagName && current.tagName.toLowerCase() === selector.toLowerCase()) { return current; } current = current.parentElement; } return null; } /** * 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) { if (source === 'class') { if (typeof this[resolvedName] === 'function') { return this[resolvedName]; } } else if (source === 'methods') { if (this.methods) { // First check event-scoped methods: methods.click.clickHandler if (this.methods[eventType] && typeof this.methods[eventType][resolvedName] === 'function') { return this.methods[eventType][resolvedName]; } // Then check global methods: methods.globalHandler if (typeof this.methods[resolvedName] === 'function') { return this.methods[resolvedName]; } } } else if (source === 'global') { if (this.enableGlobalFallback && typeof window !== 'undefined' && typeof window[resolvedName] === 'function') { return window[resolvedName]; } } } return null; } /** * Validate that a resolved handler exists and is callable * @param {string} handlerName - Original handler name for error reporting * @param {string} eventType - Event type for context * @param {Function|null} resolvedHandler - The resolved handler function * @param {string} resolvedName - The resolved method name after alias processing * @returns {boolean} - True if valid, false if validation disabled or handler missing * @private */ validateResolvedHandler(handlerName, eventType, resolvedHandler, resolvedName) { if (!this.config.enableHandlerValidation) { return !!resolvedHandler; // Skip validation but return boolean } if (!resolvedHandler) { const aliasMsg = resolvedName !== handlerName ? ` (resolved from alias '${handlerName}')` : ''; console.warn(`YpsilonEventHandler: Handler method '${resolvedName}'${aliasMsg} not found for event '${eventType}' (checked class, methods object, and global scope)`); return false; } return true; } /** * 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) { 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; }); } /** * 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, []); } // Generate handler method name with configurable prefix const handlerMethodName = typeof eventConfig === 'object' && eventConfig.handler ? eventConfig.handler : this.handlerPrefix ? `${this.handlerPrefix}${eventType.charAt(0).toUpperCase() + eventType.slice(1)}` : eventType; // Validate handler exists using enhanced resolution const validatedHandler = this.resolveHandler(handlerMethodName, eventType); const resolvedName = this.resolveMethodName(handlerMethodName, eventType); this.validateResolvedHandler(handlerMethodName, eventType, validatedHandler, resolvedName); // 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: handlerMethodName, selector: selector, 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') { const options = shouldBePassive ? { passive: true } : {}; // Add signal if AbortController is enabled if (this.abortController) { options.signal = this.abortController.signal; } return Object.keys(options).length > 0 ? options : 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; } // Add signal if AbortController is enabled and not explicitly disabled if (this.abortController && options.signal !== false) { options.signal = this.abortController.signal; } return Object.keys(options).length > 0 ? options : false; } 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; // Use the shared registration logic this.registerEventListener(element, eventConfig, `${elementSelector}_${eventType}_${index}`, elementSelector); }); }); }); return this; } /** * 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); } } abort() { if (this.abortController) { this.abortController.abort(); this.abortController = null; } return this; } destroy() { // Use AbortController for efficient cleanup if available if (this.abortController) { this.abort(); // This automatically removes ALL DOM listeners with the signal } else { // Only do manual removal when AbortController is NOT enabled this.eventListeners.forEach((config) => { config.events.forEach(({ type, handler, options }) => { config.element.removeEventListener(type, handler, options); }); }); } this.eventListeners.clear(); this.eventHandlerMap.clear(); this.distanceCache.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; } /** * 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 }, distanceCache: { size: this.distanceCache.size, enabled: this.enableDistanceCache, hitRate: this.distanceCache.size > 0 ? 'Available after multiple events' : 'No entries yet' } }; } debounce(fn, delay, key) { return YpsilonEventHandler._debounceImplementation(fn, delay, key, this.debounceTimers); } throttle(fn, delay, key) { return YpsilonEventHandler._throttleImplementation(fn, delay, key, this.throttleTimers); } detectPassiveSupport() { this.passiveSupported = YpsilonEventHandler.isPassiveSupported(); } dispatch(type, detail = null, target = document) { target.dispatchEvent(new CustomEvent(type, { bubbles: true, detail })); return this; } /** * Static dispatch method for use without instance * @param {string} type - Event type to dispatch * @param {any} detail - Event detail payload * @param {Element} target - Target element (defaults to document) * @returns {CustomEvent} - The dispatched event */ static dispatch(type, detail = null, target = document) { const event = new CustomEvent(type, { detail, bubbles: true, cancelable: true }); target.dispatchEvent(event); return event; } /** * Shared debounce implementation used by both instance and static methods * @private * @static */ static _debounceImplementation(fn, delay, key, timers) { return function(...args) { // Clear existing timer if (timers.has(key)) clearTimeout(timers.get(key)); // Set new timer with latest arguments const timerId = setTimeout(() => { fn.apply(this, args); timers.delete(key); }, delay); timers.set(key, timerId); }; } /** * Shared throttle implementation used by both instance and static methods * @private * @static */ static _throttleImplementation(fn, delay, key, timers) { return function(...args) { const timerData = timers.get(key); if (!timerData || !timerData.timeout) { // Leading edge: execute immediately fn.apply(this, args); timers.set(key, { timeout: setTimeout(() => { timers.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); timers.delete(key); }, delay); } }; } static debounce(fn, delay, key = 'default') { if (!YpsilonEventHandler._staticDebounceTimers) { YpsilonEventHandler._staticDebounceTimers = new Map(); } return YpsilonEventHandler._debounceImplementation(fn, delay, key, YpsilonEventHandler._staticDebounceTimers); } static throttle(fn, delay, key = 'default') { if (!YpsilonEventHandler._staticThrottleTimers) { YpsilonEventHandler._staticThrottleTimers = new Map(); } return YpsilonEventHandler._throttleImplementation(fn, delay, key, YpsilonEventHandler._staticThrottleTimers); } /** * Check passive support globally (cached result shared across all instances) * @returns {boolean} - True if passive listeners are supported * @static */ static isPassiveSupported() { if (YpsilonEventHandler._passiveSupportCache !== undefined) { return YpsilonEventHandler._passiveSupportCache; } try { const opts = Object.defineProperty({}, 'passive', { get: () => { YpsilonEventHandler._passiveSupportCache = true; return true; } }); window.addEventListener('testPassive', null, opts); window.removeEventListener('testPassive', null, opts); } catch (e) { YpsilonEventHandler._passiveSupportCache = false; } return YpsilonEventHandler._passiveSupportCache; } } YpsilonEventHandler._passiveSupportCache = undefined; if (typeof module !== 'undefined' && module.exports) { module.exports = { YpsilonEventHandler }; // CommonJS module.exports.default = YpsilonEventHandler; // support default export } else if (typeof window !== 'undefined') { // Browser global window['YpsilonEventHandler'] = YpsilonEventHandler; }