diff --git a/src/utils/events.js b/src/utils/events.js new file mode 100644 index 000000000..119afe27c --- /dev/null +++ b/src/utils/events.js @@ -0,0 +1,321 @@ +/** + * Bootstrap Table Event System Utility Library + * Provides jQuery-style event handling APIs using native JavaScript + */ + +import DOMHelper from './dom.js' + +class EventHelper { + /** + * Add event listener with namespace support + * @param {Element|string} element - DOM element or selector + * @param {string} event - Event name with optional namespace (e.g., "click.namespace") + * @param {Function} handler - Event handler function + * @param {Object|boolean} [options] - Event options or useCapture flag + * @returns {Element} The element itself + */ + static on (element, event, handler, options = {}) { + if (typeof element === 'string') element = DOMHelper.$(element) + if (!element) return element + + // Validate handler + if (typeof handler !== 'function') { + throw new Error('Event handler must be a function') + } + + // Parse event type and namespace + const [eventType, namespace] = event.split('.') + + // Initialize event handlers storage if not exists + if (!element._eventHandlers) { + element._eventHandlers = {} + } + if (!element._eventHandlers[eventType]) { + element._eventHandlers[eventType] = [] + } + + // Create wrapped handler to maintain context + const wrappedHandler = e => { + handler.call(element, e) + } + + // Store handler info + element._eventHandlers[eventType].push({ + handler, + namespace, + wrappedHandler, + original: handler + }) + + // Add native event listener + element.addEventListener(eventType, wrappedHandler, options) + return element + } + + /** + * Remove event listener with namespace support + * @param {Element|string} element - DOM element or selector + * @param {string} event - Event name with optional namespace + * @param {Function} [handler] - Specific handler to remove (optional) + * @returns {Element} The element itself + */ + static off (element, event, handler) { + if (typeof element === 'string') element = DOMHelper.$(element) + if (!element || !element._eventHandlers) return element + + const [eventType, namespace] = event.split('.') + + if (element._eventHandlers[eventType]) { + // Find handlers to remove + const handlersToRemove = element._eventHandlers[eventType].filter(info => { + const matches = + (!namespace || info.namespace === namespace) && + (!handler || info.original === handler) + + if (matches && info.wrappedHandler) { + element.removeEventListener(eventType, info.wrappedHandler) + } + + return matches + }) + + // Remove handlers from array + element._eventHandlers[eventType] = element._eventHandlers[eventType].filter(info => + !handlersToRemove.includes(info) + ) + + // Clean up empty event type arrays + if (element._eventHandlers[eventType].length === 0) { + delete element._eventHandlers[eventType] + } + + // Clean up if no handlers left at all + if (Object.keys(element._eventHandlers).length === 0) { + delete element._eventHandlers + } + } + + return element + } + + /** + * Trigger custom event + * @param {Element|string} element - DOM element or selector + * @param {string} event - Event name + * @param {*} [detail] - Custom data to pass with event + * @param {Object} [options] - Event options + * @returns {Element} The element itself + */ + static trigger (element, event, detail = {}, options = {}) { + if (typeof element === 'string') element = DOMHelper.$(element) + if (!element) return element + + const customEvent = new CustomEvent(event, { + detail, + bubbles: options.bubbles !== false, + cancelable: options.cancelable !== false + }) + + element.dispatchEvent(customEvent) + return element + } + + /** + * Event delegation - add event listener that works for dynamically added elements + * @param {Element|string} parent - Parent element or selector + * @param {string} selector - Child element selector + * @param {string} event - Event name with optional namespace + * @param {Function} handler - Event handler function + * @param {Object|boolean} [options] - Event options or useCapture flag + * @returns {Element} The parent element + */ + static delegate (parent, selector, event, handler, options = {}) { + if (typeof parent === 'string') parent = DOMHelper.$(parent) + if (!parent) return parent + + const [eventType, namespace] = event.split('.') + const eventWithNamespace = namespace ? `${eventType}.${namespace}` : eventType + + // Delegate handler that checks if target matches selector + const delegateHandler = e => { + const target = e.target.closest(selector) + + if (target && parent.contains(target)) { + // Set the correct context and pass target as this + handler.call(target, e) + } + } + + // Store delegate handler for potential removal + if (!parent._delegates) { + parent._delegates = [] + } + parent._delegates.push({ + selector, + eventType, + namespace, + handler: delegateHandler + }) + + // Add event listener to parent + return this.on(parent, eventWithNamespace, delegateHandler, options) + } + + /** + * Remove delegated event listener + * @param {Element|string} parent - Parent element or selector + * @param {string} selector - Child element selector + * @param {string} event - Event name with optional namespace + * @param {Function} [handler] - Specific handler to remove + * @returns {Element} The parent element + */ + static undelegate (parent, selector, event, handler) { + if (typeof parent === 'string') parent = DOMHelper.$(parent) + if (!parent || !parent._delegates) return parent + + const [eventType, namespace] = event.split('.') + const eventWithNamespace = namespace ? `${eventType}.${namespace}` : eventType + + // Find and remove matching delegate + parent._delegates = parent._delegates.filter(delegate => { + const matches = + delegate.selector === selector && + delegate.eventType === eventType && + (!namespace || delegate.namespace === namespace) && + (!handler || delegate.handler === handler) + + if (matches) { + this.off(parent, eventWithNamespace, delegate.handler) + } + + return !matches + }) + + return parent + } + + /** + * One-time event listener - automatically removed after first trigger + * @param {Element|string} element - DOM element or selector + * @param {string} event - Event name with optional namespace + * @param {Function} handler - Event handler function + * @param {Object|boolean} [options] - Event options or useCapture flag + * @returns {Element} The element itself + */ + static one (element, event, handler, options = {}) { + if (typeof element === 'string') element = DOMHelper.$(element) + if (!element) return element + + const oneTimeHandler = e => { + // Remove the listener before executing + this.off(element, event, oneTimeHandler) + // Execute the original handler + handler.call(element, e) + } + + return this.on(element, event, oneTimeHandler, options) + } + + /** + * Hover event convenience method - enter and leave handlers + * @param {Element|string} element - DOM element or selector + * @param {Function} enterHandler - Mouse enter handler + * @param {Function} leaveHandler - Mouse leave handler + * @returns {Element} The element itself + */ + static hover (element, enterHandler, leaveHandler) { + if (typeof element === 'string') element = DOMHelper.$(element) + if (!element) return element + + // Use mouseenter and mouseleave which don't bubble + if (enterHandler) { + this.on(element, 'mouseenter', enterHandler) + } + if (leaveHandler) { + this.on(element, 'mouseleave', leaveHandler) + } + + return element + } + + /** + * Get all event handlers for an element (for debugging) + * @param {Element|string} element - DOM element or selector + * @returns {Object} Event handlers object + */ + static getHandlers (element) { + if (typeof element === 'string') element = DOMHelper.$(element) + if (!element) return {} + + return element._eventHandlers || {} + } + + /** + * Remove all event listeners from an element + * @param {Element|string} element - DOM element or selector + * @param {string} [namespace] - Optional namespace to filter by + * @returns {Element} The element itself + */ + static offAll (element, namespace) { + if (typeof element === 'string') element = DOMHelper.$(element) + if (!element || !element._eventHandlers) return element + + // Get all event types + const eventTypes = Object.keys(element._eventHandlers) + + eventTypes.forEach(eventType => { + // Remove all handlers for this event type + const handlersToRemove = element._eventHandlers[eventType].filter(info => !namespace || info.namespace === namespace) + + handlersToRemove.forEach(info => { + if (info.wrappedHandler) { + element.removeEventListener(eventType, info.wrappedHandler) + } + }) + + // Update handlers array + element._eventHandlers[eventType] = element._eventHandlers[eventType].filter(info => namespace && info.namespace !== namespace) + + // Clean up if no handlers left + if (element._eventHandlers[eventType].length === 0) { + delete element._eventHandlers[eventType] + } + }) + + // Clean up if no handlers left at all + if (Object.keys(element._eventHandlers).length === 0) { + delete element._eventHandlers + } + + return element + } + + /** + * Check if element has event listeners + * @param {Element|string} element - DOM element or selector + * @param {string} [event] - Optional event type to check + * @param {string} [namespace] - Optional namespace to check + * @returns {boolean} Whether the element has listeners + */ + static hasListeners (element, event, namespace) { + if (typeof element === 'string') element = DOMHelper.$(element) + if (!element || !element._eventHandlers) return false + + if (!event) { + return Object.keys(element._eventHandlers).length > 0 + } + + const [eventType] = event.split('.') + + if (!element._eventHandlers[eventType]) return false + + if (!namespace) { + return element._eventHandlers[eventType].length > 0 + } + + return element._eventHandlers[eventType].some(info => info.namespace === namespace) + } +} + +// Export EventHelper class +export default EventHelper diff --git a/tests/utils/events.test.js b/tests/utils/events.test.js new file mode 100644 index 000000000..c226f1f40 --- /dev/null +++ b/tests/utils/events.test.js @@ -0,0 +1,483 @@ +/** + * Unit tests for EventHelper utility + */ + +import { beforeEach, describe, expect, it } from 'vitest' +import EventHelper from '@/utils/events.js' +import DOMHelper from '@/utils/dom.js' + +// Setup test environment +beforeEach(() => { + // Clear document body before each test + document.body.innerHTML = '' +}) + +describe('EventHelper', () => { + describe('Basic event handling', () => { + it('should add and trigger event listener', () => { + const element = DOMHelper.create('
Test
') + let clicked = false + + EventHelper.on(element, 'click', () => { + clicked = true + }) + + EventHelper.trigger(element, 'click') + expect(clicked).toBe(true) + }) + + it('should add event listener with selector', () => { + document.body.innerHTML = '
Test
' + let clicked = false + + EventHelper.on('#test', 'click', () => { + clicked = true + }) + + DOMHelper.$('#test').click() + expect(clicked).toBe(true) + }) + + it('should remove specific event listener', () => { + const element = DOMHelper.create('
') + let clickCount = 0 + + const handler1 = () => clickCount++ + const handler2 = () => clickCount++ + + EventHelper.on(element, 'click', handler1) + EventHelper.on(element, 'click', handler2) + + EventHelper.trigger(element, 'click') + expect(clickCount).toBe(2) + + EventHelper.off(element, 'click', handler1) + EventHelper.trigger(element, 'click') + expect(clickCount).toBe(3) + }) + + it('should handle multiple event types', () => { + const element = DOMHelper.create('
') + const events = [] + + EventHelper.on(element, 'click', () => events.push('click')) + EventHelper.on(element, 'mouseover', () => events.push('mouseover')) + + EventHelper.trigger(element, 'click') + EventHelper.trigger(element, 'mouseover') + + expect(events).toEqual(['click', 'mouseover']) + }) + }) + + describe('Namespace support', () => { + it('should add namespaced events', () => { + const element = DOMHelper.create('
') + let clicked = false + + EventHelper.on(element, 'click.namespace', () => { + clicked = true + }) + + EventHelper.trigger(element, 'click') + expect(clicked).toBe(true) + }) + + it('should remove events by namespace', () => { + const element = DOMHelper.create('
') + let ns1Count = 0 + let ns2Count = 0 + + EventHelper.on(element, 'click.ns1', () => ns1Count++) + EventHelper.on(element, 'click.ns2', () => ns2Count++) + + EventHelper.trigger(element, 'click') + expect(ns1Count).toBe(1) + expect(ns2Count).toBe(1) + + EventHelper.off(element, 'click.ns1') + EventHelper.trigger(element, 'click') + expect(ns1Count).toBe(1) + expect(ns2Count).toBe(2) + }) + + it('should handle multiple namespaces', () => { + const element = DOMHelper.create('
') + let count = 0 + + EventHelper.on(element, 'click.ns1.ns2', () => count++) + EventHelper.trigger(element, 'click') + expect(count).toBe(1) + }) + + it('should remove all events with namespace', () => { + const element = DOMHelper.create('
') + let clickCount = 0 + let mouseoverCount = 0 + + EventHelper.on(element, 'click.ns', () => clickCount++) + EventHelper.on(element, 'mouseover.ns', () => mouseoverCount++) + EventHelper.on(element, 'keydown', () => {}) + + EventHelper.offAll(element, 'ns') + + EventHelper.trigger(element, 'click') + EventHelper.trigger(element, 'mouseover') + + expect(clickCount).toBe(0) + expect(mouseoverCount).toBe(0) + }) + }) + + describe('Event delegation', () => { + it('should delegate events to child elements', () => { + const container = DOMHelper.create('
') + let clicked = false + let targetText = '' + + EventHelper.delegate(container, '.btn', 'click', e => { + clicked = true + targetText = e.target.textContent + }) + + const button = container.querySelector('.btn') + + button.click() + + expect(clicked).toBe(true) + expect(targetText).toBe('Click') + }) + + it('should work with dynamically added elements', () => { + const container = DOMHelper.create('
') + const clicks = [] + + EventHelper.delegate(container, '.item', 'click', e => { + clicks.push(e.target.textContent) + }) + + // Add button dynamically + const button1 = DOMHelper.create('') + + DOMHelper.append(container, button1) + button1.click() + + // Add another button dynamically + const button2 = DOMHelper.create('') + + DOMHelper.append(container, button2) + button2.click() + + expect(clicks).toEqual(['Item 1', 'Item 2']) + }) + + it('should not trigger on non-matching elements', () => { + const container = DOMHelper.create('
No match
') + let clicked = false + + EventHelper.delegate(container, '.btn', 'click', () => { + clicked = true + }) + + const span = container.querySelector('span') + + span.click() + + expect(clicked).toBe(false) + }) + + it('should support delegated event namespacing', () => { + const container = DOMHelper.create('
') + let clicked = false + + EventHelper.delegate(container, '.btn', 'click.ns', () => { + clicked = true + }) + + const button = container.querySelector('.btn') + + button.click() + + expect(clicked).toBe(true) + }) + + it('should undelegate events', () => { + const container = DOMHelper.create('
') + let clickCount = 0 + + const handler = () => clickCount++ + + EventHelper.delegate(container, '.btn', 'click.ns', handler) + + const button = container.querySelector('.btn') + + button.click() + expect(clickCount).toBe(1) + + EventHelper.undelegate(container, '.btn', 'click.ns') + button.click() + expect(clickCount).toBe(1) + }) + }) + + describe('Custom events and data', () => { + it('should trigger custom events with data', () => { + const element = DOMHelper.create('
') + let receivedData = null + + EventHelper.on(element, 'custom', e => { + receivedData = e.detail + }) + + const data = { message: 'Hello', value: 42 } + + EventHelper.trigger(element, 'custom', data) + + expect(receivedData).toEqual(data) + }) + + it('should support event bubbling', () => { + const parent = DOMHelper.create('
Child
') + const child = parent.querySelector('span') + const events = [] + + EventHelper.on(parent, 'click', () => events.push('parent')) + EventHelper.on(child, 'click', () => events.push('child')) + + child.click() + + expect(events).toEqual(['child', 'parent']) + }) + + it('should support cancelable events', () => { + const element = DOMHelper.create('
') + let defaultPrevented = false + + EventHelper.on(element, 'custom', e => { + e.preventDefault() + defaultPrevented = e.defaultPrevented + }) + + EventHelper.trigger(element, 'custom', {}, { cancelable: true }) + + expect(defaultPrevented).toBe(true) + }) + }) + + describe('Convenience methods', () => { + it('should handle one-time events with one()', () => { + const element = DOMHelper.create('
') + let count = 0 + + EventHelper.one(element, 'click', () => { + count++ + }) + + EventHelper.trigger(element, 'click') + EventHelper.trigger(element, 'click') + + expect(count).toBe(1) + }) + + it('should handle hover events', () => { + const element = DOMHelper.create('
') + let entered = false + let left = false + + EventHelper.hover(element, () => { + entered = true + }, () => { + left = true + }) + + // Simulate mouseenter and mouseleave + EventHelper.trigger(element, 'mouseenter') + EventHelper.trigger(element, 'mouseleave') + + expect(entered).toBe(true) + expect(left).toBe(true) + }) + + it('should handle hover with only enter handler', () => { + const element = DOMHelper.create('
') + let entered = false + + EventHelper.hover(element, () => { + entered = true + }) + + EventHelper.trigger(element, 'mouseenter') + + expect(entered).toBe(true) + }) + + it('should handle hover with only leave handler', () => { + const element = DOMHelper.create('
') + let left = false + + EventHelper.hover(element, null, () => { + left = true + }) + + EventHelper.trigger(element, 'mouseleave') + + expect(left).toBe(true) + }) + }) + + describe('Event handler management', () => { + it('should store event handlers on element', () => { + const element = DOMHelper.create('
') + const handler = () => {} + + EventHelper.on(element, 'click', handler) + + const handlers = EventHelper.getHandlers(element) + + expect(handlers.click).toBeDefined() + expect(handlers.click.length).toBe(1) + }) + + it('should support multiple handlers for same event', () => { + const element = DOMHelper.create('
') + let count = 0 + + EventHelper.on(element, 'click', () => count++) + EventHelper.on(element, 'click', () => count++) + EventHelper.on(element, 'click', () => count++) + + EventHelper.trigger(element, 'click') + + expect(count).toBe(3) + }) + + it('should remove all events with offAll()', () => { + const element = DOMHelper.create('
') + let clickCount = 0 + let mouseoverCount = 0 + + EventHelper.on(element, 'click', () => clickCount++) + EventHelper.on(element, 'mouseover', () => mouseoverCount++) + + EventHelper.offAll(element) + + EventHelper.trigger(element, 'click') + EventHelper.trigger(element, 'mouseover') + + expect(clickCount).toBe(0) + expect(mouseoverCount).toBe(0) + }) + + it('should check if element has listeners', () => { + const element = DOMHelper.create('
') + + expect(EventHelper.hasListeners(element)).toBe(false) + + EventHelper.on(element, 'click', () => {}) + + expect(EventHelper.hasListeners(element)).toBe(true) + expect(EventHelper.hasListeners(element, 'click')).toBe(true) + expect(EventHelper.hasListeners(element, 'mouseover')).toBe(false) + + EventHelper.on(element, 'click.ns', () => {}) + expect(EventHelper.hasListeners(element, 'click', 'ns')).toBe(true) + expect(EventHelper.hasListeners(element, 'click', 'other')).toBe(false) + }) + + it('should clean up event storage when empty', () => { + const element = DOMHelper.create('
') + const handler = () => {} + + EventHelper.on(element, 'click', handler) + expect(element._eventHandlers).toBeDefined() + + EventHelper.off(element, 'click', handler) + expect(element._eventHandlers).toBeUndefined() + }) + }) + + describe('Edge cases', () => { + it('should handle null/undefined elements gracefully', () => { + expect(() => { + EventHelper.on(null, 'click', () => {}) + }).not.toThrow() + + expect(() => { + EventHelper.off(undefined, 'click', () => {}) + }).not.toThrow() + + expect(() => { + EventHelper.trigger(null, 'click') + }).not.toThrow() + }) + + it('should handle non-existent selectors gracefully', () => { + expect(() => { + EventHelper.on('#nonexistent', 'click', () => {}) + }).not.toThrow() + + expect(() => { + EventHelper.off('#nonexistent', 'click', () => {}) + }).not.toThrow() + }) + + it('should handle removing non-existent handlers', () => { + const element = DOMHelper.create('
') + const handler = () => {} + + expect(() => { + EventHelper.off(element, 'click', handler) + }).not.toThrow() + + expect(() => { + EventHelper.off(element, 'nonexistent', handler) + }).not.toThrow() + }) + + it('should handle event without handler', () => { + const element = DOMHelper.create('
') + + expect(() => { + EventHelper.on(element, 'click', null) + }).toThrow() + + expect(() => { + EventHelper.on(element, 'click', undefined) + }).toThrow() + }) + + it('should maintain handler execution order', () => { + const element = DOMHelper.create('
') + const order = [] + + EventHelper.on(element, 'click', () => { + order.push('first') + }) + + EventHelper.on(element, 'click', () => { + order.push('second') + }) + + EventHelper.on(element, 'click', () => { + order.push('third') + }) + + element.click() + + expect(order).toEqual(['first', 'second', 'third']) + }) + + it('should maintain correct this context', () => { + const element = DOMHelper.create('
') + let context = null + + EventHelper.on(element, 'click', function () { + context = this + }) + + element.click() + + expect(context).toBe(element) + }) + }) +})