Element lifecycle and behavior enhancement system for reusable DOM interactions and element augmentation.
Create reusable functions that enhance DOM elements with custom behavior, event handling, or lifecycle management.
/**
* Action function that enhances a DOM element with custom behavior
* @param node - DOM element to enhance
* @param parameter - Optional parameter for configuring the action
* @returns Optional action return object with update and destroy methods
*/
interface Action<
Element = HTMLElement,
Parameter = undefined,
Attributes extends Record<string, any> = Record<never, any>
> {
(
node: Element,
parameter?: Parameter
): void | ActionReturn<Parameter, Attributes>;
}
interface ActionReturn<
Parameter = undefined,
Attributes extends Record<string, any> = Record<never, any>
> {
/** Called when the action parameter changes */
update?: (parameter: Parameter) => void;
/** Called when the element is removed from the DOM */
destroy?: () => void;
}Usage Examples:
// Basic action without parameters
function ripple(node) {
function handleClick(event) {
const rect = node.getBoundingClientRect();
const ripple = document.createElement('div');
ripple.className = 'ripple';
ripple.style.left = (event.clientX - rect.left) + 'px';
ripple.style.top = (event.clientY - rect.top) + 'px';
node.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
}
node.addEventListener('click', handleClick);
return {
destroy() {
node.removeEventListener('click', handleClick);
}
};
}
// Usage in template
<button use:ripple>Click me</button>
// Action with parameters
function tooltip(node, text) {
let tooltipElement;
function showTooltip() {
tooltipElement = document.createElement('div');
tooltipElement.className = 'tooltip';
tooltipElement.textContent = text;
document.body.appendChild(tooltipElement);
const rect = node.getBoundingClientRect();
tooltipElement.style.left = rect.left + 'px';
tooltipElement.style.top = (rect.top - tooltipElement.offsetHeight - 5) + 'px';
}
function hideTooltip() {
if (tooltipElement) {
tooltipElement.remove();
tooltipElement = null;
}
}
node.addEventListener('mouseenter', showTooltip);
node.addEventListener('mouseleave', hideTooltip);
return {
update(newText) {
text = newText;
if (tooltipElement) {
tooltipElement.textContent = text;
}
},
destroy() {
hideTooltip();
node.removeEventListener('mouseenter', showTooltip);
node.removeEventListener('mouseleave', hideTooltip);
}
};
}
// Usage with parameters
<div use:tooltip={'Hello World'}>Hover me</div>
<div use:tooltip={dynamicText}>Dynamic tooltip</div>Create type-safe actions with proper parameter and attribute typing.
Usage Examples:
interface TooltipOptions {
text: string;
position?: 'top' | 'bottom' | 'left' | 'right';
delay?: number;
}
interface TooltipAttributes {
'data-tooltip'?: string;
'aria-describedby'?: string;
}
const tooltip: Action<HTMLElement, TooltipOptions, TooltipAttributes> = (
node,
{ text, position = 'top', delay = 0 }
) => {
let timeoutId: number;
let tooltipElement: HTMLElement;
function show() {
timeoutId = setTimeout(() => {
tooltipElement = document.createElement('div');
tooltipElement.className = `tooltip tooltip-${position}`;
tooltipElement.textContent = text;
tooltipElement.id = `tooltip-${Math.random().toString(36).substr(2, 9)}`;
document.body.appendChild(tooltipElement);
node.setAttribute('aria-describedby', tooltipElement.id);
positionTooltip(tooltipElement, node, position);
}, delay);
}
function hide() {
clearTimeout(timeoutId);
if (tooltipElement) {
tooltipElement.remove();
node.removeAttribute('aria-describedby');
}
}
node.addEventListener('mouseenter', show);
node.addEventListener('mouseleave', hide);
return {
update({ text: newText, position: newPosition = 'top', delay: newDelay = 0 }) {
text = newText;
position = newPosition;
delay = newDelay;
if (tooltipElement) {
tooltipElement.textContent = text;
tooltipElement.className = `tooltip tooltip-${position}`;
positionTooltip(tooltipElement, node, position);
}
},
destroy() {
hide();
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
}
};
};
// Usage in TypeScript component
<button use:tooltip={{ text: 'Save changes', position: 'bottom', delay: 500 }}>
Save
</button>Reusable patterns for building actions that handle common DOM interaction needs.
Click Outside:
function clickOutside(node, callback) {
function handleClick(event) {
if (!node.contains(event.target)) {
callback();
}
}
document.addEventListener('click', handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
},
update(newCallback) {
callback = newCallback;
}
};
}
// Usage
let showModal = true;
<div class="modal" use:clickOutside={() => showModal = false}>
Modal content
</div>Auto-resize Textarea:
function autoresize(node) {
function resize() {
node.style.height = 'auto';
node.style.height = node.scrollHeight + 'px';
}
node.addEventListener('input', resize);
resize(); // Initial resize
return {
destroy() {
node.removeEventListener('input', resize);
}
};
}
// Usage
<textarea use:autoresize placeholder="Type here..."></textarea>Long Press:
function longpress(node, callback) {
let timeoutId;
const duration = 500; // milliseconds
function handleMouseDown() {
timeoutId = setTimeout(callback, duration);
}
function handleMouseUp() {
clearTimeout(timeoutId);
}
node.addEventListener('mousedown', handleMouseDown);
node.addEventListener('mouseup', handleMouseUp);
node.addEventListener('mouseleave', handleMouseUp);
return {
destroy() {
clearTimeout(timeoutId);
node.removeEventListener('mousedown', handleMouseDown);
node.removeEventListener('mouseup', handleMouseUp);
node.removeEventListener('mouseleave', handleMouseUp);
}
};
}
// Usage
<button use:longpress={() => alert('Long pressed!')}>
Hold me
</button>Focus Trap:
function focusTrap(node) {
const focusableElements = node.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
function handleKeyDown(event) {
if (event.key === 'Tab') {
if (event.shiftKey) {
if (document.activeElement === firstElement) {
lastElement.focus();
event.preventDefault();
}
} else {
if (document.activeElement === lastElement) {
firstElement.focus();
event.preventDefault();
}
}
}
if (event.key === 'Escape') {
node.dispatchEvent(new CustomEvent('escape'));
}
}
// Focus first element when trap is activated
firstElement?.focus();
node.addEventListener('keydown', handleKeyDown);
return {
destroy() {
node.removeEventListener('keydown', handleKeyDown);
}
};
}
// Usage
<div class="modal" use:focusTrap on:escape={() => showModal = false}>
<input type="text" placeholder="First input" />
<input type="text" placeholder="Second input" />
<button>Close</button>
</div>Actions work well as reusable libraries for common UI patterns:
// ui-actions.js
export function ripple(node, options = {}) {
const { color = 'rgba(255, 255, 255, 0.5)', duration = 600 } = options;
function createRipple(event) {
const circle = document.createElement('span');
const rect = node.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;
circle.style.cssText = `
position: absolute;
width: ${size}px;
height: ${size}px;
left: ${x}px;
top: ${y}px;
background: ${color};
border-radius: 50%;
pointer-events: none;
transform: scale(0);
animation: ripple ${duration}ms ease-out;
`;
node.appendChild(circle);
setTimeout(() => circle.remove(), duration);
}
// Ensure node is positioned
if (getComputedStyle(node).position === 'static') {
node.style.position = 'relative';
}
node.style.overflow = 'hidden';
node.addEventListener('click', createRipple);
return {
update(newOptions) {
Object.assign(options, newOptions);
},
destroy() {
node.removeEventListener('click', createRipple);
}
};
}
export function lazyload(node, src) {
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
node.src = src;
observer.disconnect();
}
});
observer.observe(node);
return {
update(newSrc) {
src = newSrc;
},
destroy() {
observer.disconnect();
}
};
} else {
// Fallback for browsers without IntersectionObserver
node.src = src;
}
}
// Usage
import { ripple, lazyload } from './ui-actions.js';
<button use:ripple={{ color: 'rgba(0, 100, 255, 0.3)' }}>
Ripple Button
</button>
<img use:lazyload={'./large-image.jpg'} alt="Lazy loaded" />