UNPKG

ypsilon-event-handler

Version:

A production-ready event handling system for web applications with memory leak prevention, and method chaining support

636 lines (540 loc) 30.8 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Comprehensive SPA Demo (Single Page Application) - YpsilonEventHandler</title> <meta name="description" content="YpsilonEventHandler - Comprehensive Single Page Application [SPA] Example Page"> <link rel="icon" type="image/x-icon" href="./favicon.ico"> <link rel="stylesheet" type="text/css" href="./assets/main.css"> <style> body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; margin: 0 auto; padding: 2rem; max-width: 1200px; } .y-btn { padding: 0.7rem 1.2rem; margin: 0.25rem; background: #3b82f6; color: white; border: none; border-radius: 0.375rem; cursor: pointer; } .y-btn:hover { background: #2563eb; } .y-btn:disabled { background: #94a3b8; cursor: not-allowed; } .y-input { padding: 0.5rem; margin: 0.25rem; border: 1px solid #d1d5db; border-radius: 0.375rem; } .demo-section { margin: 2rem 0; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; } .demo-section h3 { margin-top: 0; color: #374151; } .output { padding: 1rem; margin: 1rem 0; background: #f3f4f6; border-radius: 0.375rem; font-family: monospace; } .navigation { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 5px; text-align: center; margin: 2rem 0; } .navigation a { margin: 0; padding: 0.5rem 1rem; flex: 1; white-space: nowrap; background: #6b7280; color: white; text-decoration: none; border-radius: 0.375rem; } .navigation a:hover { background: #4b5563; } .scroll-to-top-container { padding: 0; position: fixed; bottom: 8px; right: 8px; opacity: 1; outline: 0; } .scroll-to-top { font-size: 18px; margin: 0; padding: 0.5rem 1rem; line-height: 1; opacity: .0; position: absolute; bottom: 0; right: 0; z-index: 1001; background: #264371; transform: translate(68px, 0); transition: opacity .6s ease-in-out, transform .3s ease-in-out; } .y-scrolled .scroll-to-top { transform: translate(-1px, 0); opacity: 1; } .quote { display:inline-block; margin: 1em 2rem; padding: 1em; background: linear-gradient(135deg, #c9d3ff25 0%, #b281e240 100%); } .quote blockquote { background-color: #fff; display: inline-block; margin: 0; padding: 1em; position: relative; } .quote blockquote p { margin: 0; font-weight: 500; &:after, &:before { content: '"'; }} .quote blockquote cite { margin-top: .3rem; display: block; font-style: italic; font-size: .9em; color: #444; &:before { content: "— "; }} </style> </head> <body class="y-scroll-top"> <header> <h1>YpsilonEventHandler - Comprehensive Template</h1> <p>A complete, working template demonstrating all YpsilonEventHandler patterns. Perfect starting point for any project.</p> <div class="quote"> <blockquote> <p>Just remove what you don't like and start from there.</p> <cite>Ypsilon Team</cite> </blockquote> </div> </header> <div class="demo-section"> <h3>🖱️ Click Events with Data-Action Routing</h3> <div style="background: #fef9c3; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; border-left: 4px solid #f59e0b;"> <h4 style="margin: 0 0 0.5rem 0; color: #92400e;">🤯 Mind-Blowing Fact:</h4> <p style="margin: 0; font-size: 0.9rem; color: #92400e;"> <strong>ZERO</strong> of these buttons have event listeners attached to them! We have <strong>ONE</strong> click listener on the <code>&lt;body&gt;</code> element that controls <strong>ALL</strong> buttons on this page. New buttons added dynamically? <strong>Still work instantly</strong>, because for the listener nothing has changed, it's still the same element it's listening to - no re-assignment needed! </p> </div> <div class="btn-group"> <button class="y-btn" data-action="appTestDispatch">Test Custom Event</button> <button class="y-btn" data-action="scrollToTop">Scroll to Top</button> <button class="y-btn" data-action="removeElement" data-target="#removable">Remove Element</button> <button class="y-btn" data-action="removeElements" data-target=".removable">Remove All .removable</button> <button class="y-btn" data-action="createDynamicButton">Create Dynamic Button</button> </div> <div id="dynamic-buttons" style="margin-top: 1rem; padding: 1rem; background: #ecfdf5; border-radius: 0.375rem; border-left: 4px solid #10b981;"> <h4 style="margin: 0 0 0.5rem 0; color: #065f46;">✨ Dynamic Button Playground:</h4> <p style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: #065f46;"> Click "Create Dynamic Button" to add new buttons. They work instantly without any event listener setup! </p> <div id="dynamic-button-container"></div> </div> <div id="removable" style="margin-top: 1rem; padding: 1rem; background: #fef3c7; border-radius: 0.375rem;"> <strong>Removable Element:</strong> Click "Remove Element" to remove this box. </div> <div class="removable" style="margin-top: 0.5rem; padding: 0.5rem; background: #fde68a; border-radius: 0.375rem;"> Removable item #1 </div> <div class="removable" style="margin-top: 0.5rem; padding: 0.5rem; background: #fde68a; border-radius: 0.375rem;"> Removable item #2 </div> </div> <div class="demo-section"> <h3>🚀 Custom Events</h3> <p>YpsilonEventHandler includes a powerful <code>dispatch()</code> method for custom events.</p> <div class="output" id="custom-output">Custom events will appear here...</div> </div> <div class="demo-section"> <h3>📝 Input Events with Debouncing</h3> <input type="text" name="demo-1" class="y-input" data-action="demoInputHandler" placeholder="Type something (debounced 300ms)"> <input type="email" name="demo-2" class="y-input" data-action="demoInputHandler" placeholder="Email validation demo"> <div class="output" id="input-output">Input events will appear here...</div> </div> <div class="demo-section"> <h3>🔄 Change Events</h3> <select name="demo-3" class="y-input" data-action="demoChangeHandler"> <option value="">Select an option</option> <option value="option1">Option 1</option> <option value="option2">Option 2</option> <option value="option3">Option 3</option> </select> <div class="output" id="change-output">Change events will appear here...</div> </div> <div class="demo-section"> <h3>⌨️ Keyboard Events</h3> <p>Press any key while focused on this page to see keydown events in the console.</p> <div class="output" id="key-output">Press a key to see keydown events...</div> </div> <div class="demo-section"> <h3>📏 Window Events</h3> <p>Resize the window or scroll to see throttled events in the console.</p> <div class="output" id="window-output">Window events will appear here...</div> </div> <div class="demo-section"> <h3>⚡ State Management (Ypsiwork-inspired)</h3> <p>Reactive state management with automatic UI updates using <code>data-state</code> attributes.</p> <button class="y-btn" data-action="incrementClickCount">Increment Click Count</button> <button class="y-btn" data-action="resetClickCount">Reset Count</button> <div class="output"> <p>Click count: <span data-state="clickCount">0</span></p> <p>Last key pressed: <span data-state="lastKeyPressed">none</span></p> <p>Current input: <span data-state="inputValue">empty</span></p> <p>Window size: <span data-state="windowWidth">0</span>x<span data-state="windowHeight">0</span></p> </div> <p><small>State updates automatically sync across all <code>data-state</code> elements!</small></p> </div> <div style="height: 1000px; background: linear-gradient(to bottom, #f3f4f6, #e5e7eb); margin: 2rem 0; display: flex; align-items: center; justify-content: center;"> <p style="font-size: 1.2rem; color: #6b7280;">Scroll content for testing scroll events</p> </div> <div class="nav-container"> <!-- Navigation to other examples --> <nav class="main-nav"> <div> <a href="./index.html" class="btn-primary">Start</a> <a href="./basic-example.html" class="btn-secondary">Basic Example</a> <a href="./reactive-y.html" class="btn-danger">Reactive Demo</a> <a href="./single-listener-multiple-actions.html" class="btn-purple">Single Listener</a> <a href="./spa.html" class="btn-success">SPA Demo</a> <a href="./ai-reviews.html" class="btn-warning">AI Reviews</a> <a href="https://github.com/eypsilon/YpsilonEventHandler" class="btn-dark">GitHub</a> </div> </nav> </div> <!-- Scroll to top --> <div class="scroll-to-top-container"> <button type="button" class="scroll-to-top btn y-btn" data-action="scrollToTop"></button> </div> <!-- Include scripts always at the end of body. No need for async or defer. --> <script src="https://cdn.jsdelivr.net/npm/ypsilon-event-handler@1.5.0/ypsilon-event-handler.min.js"></script> <script> class AppEventHandler extends YpsilonEventHandler { constructor() { /** * What does delegation even mean? * * Imagine YpsilonEventHandler as a routing system for Events, scoped to * the class that extends it. Its whole purpose is to listen to Events, and * when the Event gets fired, find the correct handler and call it. * * That's it. That's the whole purpose `YpsilonEventHandler` has. It's a more * interactive configuration tool, but a configuration nonetheless. You * practically tell the system: * * > "Hey sys, when element 'body' fires a click event, call handleClick with options" * > "Hey sys, when element 'window' fires a scroll event, call handleScroll" * > "Hey sys, when element '#ctx-btn' fires a click event, call importantBtn" * * Once the events are routed, you can from there on use data-attributes to control * the behavior, like a handler to call. Yes, in super(), you configure your superHandler, * and the affected elements can then set their own handlers. So when all clicks on body * are listened to, you can control all click trigger elements via data-attributes. * This also works for dynamically created elements. * * For most cases the following setup will already do 99% of whatever * you plan to do. The rest can be easily extended. * * Each event type that has no handler declared will fall back to the convention: * 'handle' + 'capitalized type', e.g. * 'click' → 'handleClick', * 'input' → 'handleInput' */ super({ // 'document': [{ type: 'visibilitychange', capture: true }], 'body': [ 'click', 'change', 'keydown', 'testdispatch', { type: 'input', debounce: 300 }, // { type: 'click' }, // { type: 'change' }, // { type: 'keydown' }, // { type: 'testdispatch' } ], 'window': [ { type: 'scroll', throttle: 150 }, { type: 'resize', throttle: 500 }, { type: 'beforeunload', capture: true } ], }); // Initialize state management system (inspired by Ypsiwork) this.state = { clickCount: 0, lastKeyPressed: 'none', inputValue: '', windowWidth: window.innerWidth, windowHeight: window.innerHeight }; // Auto-detect data attributes and register missing events this.autoRegisterEvents(); // Set up MutationObserver for dynamic event registration this.setupMutationObserver(); // Initialize reactive text bindings this.initializeStateBindings(); } /** * Event notes * * 'visibilityChange' works reliably for task-switching on mobile platforms. * 'beforeunload' is of limited value as it only fires on desktop navigations. * 'unload' does not fire on mobile and desktop Safari. */ /** * Useful to perform final actions right before user leaves */ handleVisibilitychange(event) { if (document.visibilityState == 'hidden') { console.log('Bye bye!') } } /** * If user has interacted with the page, this method acts as a * `prevent-data-loss` tool, a pretty unreliable `prevent-data-loss` tool. * * This can be used as is. The getter @method 'hasUserInteracted' gets set internally. That check is important * for at least the User XP and of course Google Chrome. When the user hasn't touched the page at all, * Chrome throws an Error: [Intervention] Blocked attempt to show a 'beforeunload' ... * * The @method 'hasUserInteracted' prevents that. */ handleBeforeunload(event) { if (this.hasUserInteracted()) { event.preventDefault(); event.returnValue = 'Are you sure you want to leave Ypsilon?'; } else { // User hasn't interacted, clean up silently this.destroy(); } } /** * Reusable pattern for multiple events with condition check. Apply to 'input', 'change', etc. * Set markers to Event triggering Elements and ignore all unmarked elements when they fire. * * Performance Boost: pretty high, depends on usage, potentially significant * * Delegation Philosophy: We listen on parents, not children. It doesn't matter how many * children a listening element contains - we target the parents because it's easier to * control children through their parents than vice versa. One parent can have many children, * but one child has only one parent (technically speaking). Events bubble up from children * to parents, so we catch them at the parent level and route them accordingly. */ handleClick(event, target) { // The 2 if statements below can be combined. if (!(target instanceof Element)) return; // Just wanna make sure nobody misses this part if (!target.classList.contains('y-btn')) return; /** * Method/handler routing using data-attributes like data-action or data-callback. * * When an Event reaches this point, means it was marked. * The action attribute could be counted as second condition check. */ const { action, // target.dataset.action || data-action="methodName" blur, // target.dataset.blur || data-blur preventDefault, // target.dataset.preventDefault || data-prevent-default stopPropagation // target.dataset.stopPropagation || data-stop-propagation } = target.dataset; if (!action) return; if (typeof blur !== 'undefined') target.blur(); if (typeof preventDefault !== 'undefined') event.preventDefault(); if (typeof stopPropagation !== 'undefined') event.stopPropagation(); // Final action call, the handler to call must in the class scope. if (typeof this[action] === 'function') { return this[action](target, event); } // I'm using this pattern since ~2012, and practically nothing has changed. // You ask, how did my code looked back then? Like above. Ok, I admit, // the way we can declare constants nowadays is much cooler. } /** * Same as `handleClick`, you can use your own conventions, but the core logic is the same. */ handleInput(event, target) { // The 2 if statements below can be combined. if (!(target instanceof Element)) return; if (!target.classList.contains('y-input')) return; // If you need some fancy button extras for input, select, etc., just copy & paste // it to here and adjust to your needs. const action = target.dataset.action; if (!action) return; if (typeof this[action] === 'function') { return this[action](target, event); } } handleKeydown(event, target) { console.log(event.key) document.getElementById('key-output').textContent = `Key pressed: ${event.key} (${event.code})`; // Update state this.setState('lastKeyPressed', event.key); } handleResize(event, target) { console.log(window.innerWidth, 'x', window.innerHeight) document.getElementById('window-output').textContent = `Window resized: ${window.innerWidth}x${window.innerHeight}`; // Update state this.setState('windowWidth', window.innerWidth); this.setState('windowHeight', window.innerHeight); } handleChange(event, target) { console.log(event.type, event) const action = target.dataset.action; if (action && typeof this[action] === 'function') { return this[action](target, event); } } handleScroll(event, target) { const scrollY = window.scrollY || window.pageYOffset; const windowHeight = window.innerHeight; const docHeight = document.documentElement.scrollHeight; this.updateScrollClasses(scrollY, docHeight, windowHeight); // Manually throttle if (!this.waitingScroll) { this.waitingScroll = true; setTimeout(() => { this.waitingScroll = false; console.log(`🌐 Window scroll: ${Math.round(scrollY)}px`); }, 250); } } updateScrollClasses(scrollY, docHeight, windowHeight) { const body = document.body; const isAtTop = scrollY <= 100; const isAtBottom = scrollY + windowHeight >= docHeight - 30; // Only change classes when needed to prevent flicker if (isAtTop) { // At top: only y-scroll-top if (!body.classList.contains('y-scroll-top')) { body.classList.remove('y-scrolled', 'y-scroll-bottom'); body.classList.add('y-scroll-top'); } } else { // Not at top: remove y-scroll-top, add y-scrolled if (body.classList.contains('y-scroll-top')) { body.classList.remove('y-scroll-top'); } if (!body.classList.contains('y-scrolled')) { body.classList.add('y-scrolled'); } // Handle bottom class addition/removal if (isAtBottom && !body.classList.contains('y-scroll-bottom')) { body.classList.add('y-scroll-bottom'); } else if (!isAtBottom && body.classList.contains('y-scroll-bottom')) { body.classList.remove('y-scroll-bottom'); } } } // Call this function, to trigger `handleTestdispatch` below. appTestDispatch() { // this will call the method `handleTestdispatch` below, when dispatched. this.dispatch('testdispatch', { timestamp: Date.now() }, document.body); } handleTestdispatch(event, target) { // This is an undeclared listener that listens while it's not listening. // If that makes sense. Useful however after AJAX calls for example. console.log(`🔥 Custom Event: ${event.type} - Detail: ${JSON.stringify(event.detail)}`); document.getElementById('custom-output').textContent = `Custom Event: ${event.type} - Detail: ${JSON.stringify(event.detail)}`; } /** * Demo handlers for the interactive examples */ demoInputHandler(target, event) { const value = target.value; const type = target.type; const timestamp = new Date().toLocaleTimeString(); document.getElementById('input-output').textContent = `[${timestamp}] ${type} input: "${value}"`; // Update state this.setState('inputValue', value); } demoChangeHandler(target, event) { const value = target.value; const timestamp = new Date().toLocaleTimeString(); document.getElementById('change-output').textContent = `[${timestamp}] Selection changed to: "${value}"`; } // State management demo methods incrementClickCount(target, event) { const currentCount = this.getState('clickCount'); this.setState('clickCount', currentCount + 1); } resetClickCount(target, event) { this.setState('clickCount', 0); } // Dynamic button creation demo createDynamicButton(target, event) { const container = document.getElementById('dynamic-button-container'); const buttonCount = container.children.length + 1; const newButton = document.createElement('button'); newButton.className = 'y-btn'; newButton.style.margin = '0.25rem'; newButton.setAttribute('data-action', 'dynamicButtonClick'); newButton.textContent = `Dynamic Button #${buttonCount}`; container.appendChild(newButton); // Show that no event listener registration is needed! console.log(`🎉 Created Dynamic Button #${buttonCount} - NO event listener registration needed!`); } // Handler for dynamically created buttons dynamicButtonClick(target, event) { const buttonText = target.textContent; alert(`🚀 ${buttonText} clicked! This button was created dynamically but works instantly thanks to event delegation!`); // Increment click count to show state management also works const currentCount = this.getState('clickCount'); this.setState('clickCount', currentCount + 1); } /** * Some helpers one always needs */ scrollToTop(target, event) { if (!(target instanceof Element) || !target.classList.contains('y-btn')) return; // If click event is set for body, just put the button to your footer // <button class="y-btn" data-action="scrollToTop"></button> window.scrollTo({ top: 0, behavior: 'smooth' }); } removeElement(target, event) { if (!(target instanceof Element) || !target.classList.contains('y-btn')) return; // Remove a targeted element // <button class="y-btn" data-action="removeElement" data-target="#header"></button> const element = document.querySelector(target.dataset.target); if (element) { element.remove(); } } removeElements(target, event) { if (!(target instanceof Element) || !target.classList.contains('y-btn')) return; // Remove targeted elements // <button class="y-btn" data-action="removeElements" data-target=".isLoading"></button> document.querySelectorAll(target.dataset.target).forEach(rm => rm.remove()); } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Advanced features inspired by Ypsiwork framework */ // State management system getState(key) { return this.state[key]; } setState(key, value) { console.log(`🔄 Setting state: ${key} = ${value}`); this.state[key] = value; this.updateStateBindings(key); } // Auto-detect data attributes and register missing events autoRegisterEvents() { console.log('🔍 Scanning DOM for data-* attributes...'); // Find all elements with data-* attributes that look like event handlers const allElements = document.querySelectorAll('*'); const eventAttributes = new Set(); allElements.forEach(element => { Array.from(element.attributes).forEach(attr => { // Look for data-action attributes (our existing pattern) if (attr.name === 'data-action' && attr.value) { // This is already handled by our existing system return; } // Look for data-event-* patterns for future extensibility if (attr.name.startsWith('data-event-')) { const eventType = attr.name.replace('data-event-', ''); eventAttributes.add(eventType); } }); }); if (eventAttributes.size > 0) { console.log('🎯 Found data-event-* attributes:', [...eventAttributes]); // Could extend event registration here if needed } } // Set up MutationObserver for dynamic content setupMutationObserver() { console.log('👁️ Setting up MutationObserver for dynamic content'); this.mutationObserver = new MutationObserver((mutations) => { let needsUpdate = false; mutations.forEach((mutation) => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { // Check for new data-state elements const stateElements = node.querySelectorAll ? node.querySelectorAll('[data-state]') : []; if (stateElements.length > 0) { needsUpdate = true; } } }); } }); if (needsUpdate) { console.log('🔄 DOM changes detected, updating state bindings...'); this.initializeStateBindings(); } }); this.mutationObserver.observe(document.body, { childList: true, subtree: true }); } // Initialize reactive state bindings initializeStateBindings() { const stateElements = document.querySelectorAll('[data-state]'); console.log(`📝 Found ${stateElements.length} data-state elements`); stateElements.forEach(element => { const stateKey = element.getAttribute('data-state'); this.updateStateElement(element, stateKey); }); } // Update a single state-bound element updateStateElement(element, stateKey) { const value = this.getState(stateKey); if (value !== undefined) { element.textContent = value; } } // Update all elements bound to a specific state key updateStateBindings(stateKey) { const elements = document.querySelectorAll(`[data-state="${stateKey}"]`); console.log(`🔄 Updating ${elements.length} elements for state.${stateKey}`); elements.forEach(element => { this.updateStateElement(element, stateKey); }); } } let spa = new AppEventHandler(); window.spa = spa; </script> </body> </html>