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
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><body></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>