UNPKG

watch-selector

Version:

Runs a function when a selector is added to dom

1,664 lines (1,569 loc) 71.5 kB
/** * @module watch-selector/events * * # The Unified Event System * * This module provides a powerful, unified API for handling DOM and lifecycle * events. It seamlessly supports both standalone usage (on a specific DOM * element) and usage within a `watch()` generator context. * * ## Key Features * * - **Full Type Safety:** Event objects and their `detail` payloads are * correctly and automatically typed. * - **Generator-Powered Handlers:** Event handlers can be generators, allowing * you to `yield` other library functions for complex, asynchronous flows. * - **Robust Feature Set:** Includes event delegation, debouncing, throttling, * event filtering, and async generator queuing. * - **Automatic Cleanup:** All listeners are automatically cleaned up when their * associated element is removed from the DOM. * - **Standalone & Generator Compatibility:** The same functions work identically * everywhere, with or without a `watch()` context. */ import type { ElementFn, CleanupFunction, HybridEventOptions, HybridEventHandler, HybridCustomEventHandler, AttributeChange, TextChange, VisibilityChange, ResizeChange, Workflow, Operation, WatchContext, } from "../types"; import { executeGenerator, getCurrentContext, createCleanupFunction, pushContext, popContext, executeCleanup, } from "../core/context"; // ==================== MAIN 'on' FUNCTION ==================== // --- Overloads for strong type inference and API documentation --- // 1. Standard DOM events (e.g., 'click', 'input') export function on<El extends Element, K extends keyof HTMLElementEventMap>( element: El, event: K, handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): CleanupFunction; export function on<K extends keyof HTMLElementEventMap>( selector: string, event: K, handler: HybridEventHandler<HTMLElement, K>, options?: HybridEventOptions, ): CleanupFunction | null; export function on<El extends Element, K extends keyof HTMLElementEventMap>( event: K, handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): ElementFn<El, CleanupFunction>; // Workflow overload for yield* usage with DOM events export function on<El extends Element, K extends keyof HTMLElementEventMap>( event: K, handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): Workflow<CleanupFunction>; // 2. CustomEvents with a specific detail type T export function on<El extends Element, T>( element: El, event: CustomEvent<T>, handler: HybridCustomEventHandler<El, T>, options?: HybridEventOptions, ): CleanupFunction; export function on<T>( selector: string, event: CustomEvent<T>, handler: HybridCustomEventHandler<HTMLElement, T>, options?: HybridEventOptions, ): CleanupFunction | null; export function on<El extends Element, T>( event: CustomEvent<T>, handler: HybridCustomEventHandler<El, T>, options?: HybridEventOptions, ): ElementFn<El, CleanupFunction>; // Workflow overload for yield* usage with CustomEvents export function on<El extends Element, T>( event: CustomEvent<T>, handler: HybridCustomEventHandler<El, T>, options?: HybridEventOptions, ): Workflow<CleanupFunction>; // 3. Custom event strings, requiring a generic for the detail type T export function on<T = any, El extends Element = HTMLElement>( element: El, eventType: string, handler: HybridCustomEventHandler<El, T>, options?: HybridEventOptions, ): CleanupFunction; export function on<T = any>( selector: string, eventType: string, handler: HybridCustomEventHandler<HTMLElement, T>, options?: HybridEventOptions, ): CleanupFunction | null; export function on<T = any, El extends Element = HTMLElement>( eventType: string, handler: HybridCustomEventHandler<El, T>, options?: HybridEventOptions, ): ElementFn<El, CleanupFunction>; // Workflow overload for yield* usage with custom event strings export function on<T = any, El extends Element = HTMLElement>( eventType: string, handler: HybridCustomEventHandler<El, T>, options?: HybridEventOptions, ): Workflow<CleanupFunction>; /** * # on() - The Unified Event Listener * * Attaches a powerful, context-aware event listener to an element. It supports * standard DOM events, CustomEvents, and a rich set of features like delegation, * debouncing, throttling, and generator-based handlers. * * This function is the foundation of all event handling in the library and * supports the full dual API pattern with direct elements, CSS selectors, and * generator mode. * * @example * // Standard click handler (type of `event` is MouseEvent) * yield on('click', (event) => console.log(event.clientX)); * * @example * // CSS selector usage * on('#submit-button', 'click', () => { * console.log('Submit clicked!'); * }); * * @example * // Custom event with typed detail * const userEvent = createCustomEvent('user:login', { id: 1, name: 'John' }); * yield on(userEvent, (event) => console.log(event.detail.name)); // event.detail is {id, name} * * @example * // Generator handler for complex interactions * yield on('click', function* (event) { * yield addClass('loading'); * const data = yield* fetch('/api/data'); * yield updateUI(data); * yield removeClass('loading'); * }); * * @example * // Standalone usage with delegation * const container = document.getElementById('container'); * const cleanup = on(container, 'click', (event, delegatedEl) => { * console.log('Clicked on:', delegatedEl.textContent); * }, { delegate: '.item' }); */ export function on<El extends Element, K extends keyof HTMLElementEventMap, T>( ...args: any[] ): any { // Check if we're in a generator context and need to return a Workflow const context = getCurrentContext(); const isWorkflowUsage = context && !args[0]?.nodeType && // Not a direct element typeof args[0] === "string" && // Event type string typeof args[1] === "function" && // Handler function args.length <= 3; // Not selector usage (which has 4 args) if (isWorkflowUsage) { // Return a Workflow for yield* usage return (function* (): Generator< Operation<CleanupFunction>, CleanupFunction, any > { const op: Operation<CleanupFunction> = (ctx: WatchContext) => { const element = ctx.element; if (!element) { console.error("[DEBUG] Element is undefined in operation context!"); throw new Error("Element is undefined in event operation context"); } const eventOrType = args[0]; const handler = args[1]; const options = args[2] || {}; const eventType = getEventType(eventOrType); const inGeneratorContext = true; const enhancedHandler = createEnhancedHandler( element as unknown as HTMLElement, handler, inGeneratorContext, options, ); const finalHandler = applyTimingModifiers(enhancedHandler, options); const cleanup = setupEventListener( element, eventType, finalHandler, options, ); createCleanupFunction(element as unknown as HTMLElement)(cleanup); return cleanup; }; const cleanup = yield op; return cleanup; })(); } // 1. UNIFIED ARGUMENT PARSING const isDirectUsage = args[0] instanceof Element; // Check for CSS selector usage: first arg is string, second arg is also string (event type), // and we have at least 3 args total (selector, event, handler) // This distinguishes from generator pattern where first arg is event type string and second is handler function const isSelectorUsage = typeof args[0] === "string" && typeof args[1] === "string" && args.length >= 3 && !isDirectUsage; // Handle CSS selector usage if (isSelectorUsage) { const selector = args[0] as string; const eventOrType = args[1]; const handler = args[2]; const options = args[3] || {}; // Find element by selector const element = document.querySelector(selector) as HTMLElement | null; if (!element) { console.warn(`No element found for selector: ${selector}`); return null; } // Call on with the found element - pass all args properly return on(element, eventOrType, handler, options); } const element = isDirectUsage ? (args[0] as El) : null; const eventOrType = (isDirectUsage ? args[1] : args[0]) as | K | string | CustomEvent<T>; const handler = (isDirectUsage ? args[2] : args[1]) as | HybridEventHandler<El, K> | HybridCustomEventHandler<El, T>; const options = ((isDirectUsage ? args[3] : args[2]) as HybridEventOptions) || {}; // 2. CREATE THE UNIFIED ELEMENT FUNCTION const elementFn: ElementFn<El, CleanupFunction> = (el: El) => { const eventType = getEventType(eventOrType); const inGeneratorContext = !!getCurrentContext(); const enhancedHandler = createEnhancedHandler( el as unknown as HTMLElement, handler, inGeneratorContext, options, ); const finalHandler = applyTimingModifiers(enhancedHandler, options); const cleanup = setupEventListener(el, eventType, finalHandler, options); if (inGeneratorContext) { createCleanupFunction(el as unknown as HTMLElement)(cleanup); } return cleanup; }; // 3. EXECUTE OR RETURN return isDirectUsage && element ? elementFn(element) : elementFn; } // ==================== INTERNAL HELPERS (Unified & Improved) ==================== function createEnhancedHandler< El extends HTMLElement, K extends keyof HTMLElementEventMap, T, >( element: El, handler: HybridEventHandler<El, K> | HybridCustomEventHandler<El, T>, inGeneratorContext: boolean, options: HybridEventOptions, ): (event: Event) => Promise<void> { return async (event: Event) => { try { let targetElement: El = element; if (options.delegate) { const delegateTarget = (event.target as Element)?.closest?.( options.delegate, ); if (!delegateTarget || !element.contains(delegateTarget)) return; targetElement = delegateTarget as El; } if ( options.filter && !options.filter(event, targetElement as unknown as HTMLElement) ) return; let result: any; const context = getCurrentContext() || { element: targetElement, selector: "event", index: 0, array: [targetElement], }; // Check if handler might return a generator without calling it const handlerStr = handler.toString(); const isGeneratorFunction = handlerStr.includes("function*") || handlerStr.includes("async function*"); if (isGeneratorFunction) { // Delay generator creation until after queue management const queueMode = options.queue || "all"; if (queueMode === "none") { executeGenerator( targetElement as unknown as HTMLElement, "event", 0, [targetElement as unknown as HTMLElement], () => { pushContext(context); try { return (handler as Function)(event, targetElement); } finally { if (!inGeneratorContext) popContext(); } }, undefined, // No abort signal for "none" mode ).catch((e) => console.error("Error in concurrent generator", e)); } else { await handleQueuedExecution(targetElement, queueMode, (signal) => executeGenerator( targetElement as unknown as HTMLElement, "event", 0, [targetElement as unknown as HTMLElement], () => { pushContext(context); try { return (handler as Function)(event, targetElement); } finally { if (!inGeneratorContext) popContext(); } }, signal, // Pass abort signal for cancellation ), ); } } else { // Non-generator function - call normally pushContext(context); try { result = (handler as Function)(event, targetElement); } finally { // Only pop the context if we are in standalone mode (i.e., we pushed a temporary one). if (!inGeneratorContext) popContext(); } if (result && typeof result.then === "function") { await result; } } } catch (error) { console.error("Error in event handler:", error); } }; } interface QueuedExecution { promise: Promise<any>; controller?: AbortController; } const elementQueues = new WeakMap<Element, QueuedExecution>(); async function handleQueuedExecution<El extends Element>( element: El, queueMode: "latest" | "all", executor: (signal?: AbortSignal) => Promise<any>, ): Promise<void> { let executionPromise: Promise<any>; let controller: AbortController | undefined; const lastExecution = elementQueues.get(element); const lastPromise = lastExecution?.promise || Promise.resolve(); if (queueMode === "all") { // Queue all executions sequentially executionPromise = lastPromise.then( () => executor(), () => executor(), ); } else { // 'latest' - cancel previous execution if it exists if (lastExecution?.controller) { lastExecution.controller.abort(); } controller = new AbortController(); executionPromise = executor(controller.signal); } const currentExecution: QueuedExecution = { promise: executionPromise, controller, }; elementQueues.set(element, currentExecution); try { await executionPromise; } catch (error) { // Ignore abort errors if (error instanceof Error && error.name === "AbortError") { return; } throw error; } finally { if (elementQueues.get(element) === currentExecution) { elementQueues.delete(element); } } } function applyTimingModifiers( handler: (event: Event) => Promise<void>, options: HybridEventOptions, ): (event: Event) => void { if (options.debounce) return debounce(handler, options.debounce); if (options.throttle) return throttle(handler, options.throttle); return (event: Event) => { handler(event).catch((e) => console.error("Async event handler error:", e)); }; } function setupEventListener( element: Element, eventType: string, handler: (event: Event) => void, options: HybridEventOptions, ): CleanupFunction { const listenerOptions: AddEventListenerOptions = { capture: options.delegate ? options.delegatePhase === "capture" : options.capture, once: options.once, passive: options.passive, signal: options.signal, }; if (!element) { console.error("[DEBUG] Element is undefined in setupEventListener!"); throw new Error("Element is undefined in setupEventListener"); } // console.log( // "[DEBUG] setupEventListener - element received:", // element?.tagName, // typeof element, // ); // console.log( // "[DEBUG] Right before addEventListener - element:", // element, // "hasMethod:", // typeof element?.addEventListener, // ); if (!element) { throw new Error("Element is null/undefined right before addEventListener"); } if (typeof element.addEventListener !== "function") { console.error( "[DEBUG] addEventListener is not a function:", typeof element.addEventListener, ); console.error("[DEBUG] Element keys:", Object.keys(element)); console.error("[DEBUG] Element prototype:", Object.getPrototypeOf(element)); throw new Error("Element.addEventListener is not a function"); } element.addEventListener(eventType, handler, listenerOptions); return () => { if (options.signal?.aborted) return; element.removeEventListener(eventType, handler, listenerOptions); }; } function getEventType<T>(eventOrType: string | CustomEvent<T>): string { if (typeof eventOrType === "string") return eventOrType; if (eventOrType instanceof CustomEvent) return eventOrType.type; throw new Error("Invalid event type provided."); } function debounce( func: (event: Event) => Promise<void>, options: number | { wait: number; leading?: boolean; trailing?: boolean }, ) { const config = typeof options === "number" ? { wait: options, trailing: true, leading: false } : { trailing: true, leading: false, ...options }; let timeoutId: any; let lastArgs: [Event] | null = null; let isLeading = true; return (event: Event) => { lastArgs = [event]; clearTimeout(timeoutId); if (config.leading && isLeading) { isLeading = false; func(...lastArgs).catch((e) => console.error("Error in debounced handler:", e), ); } timeoutId = setTimeout(() => { if (config.trailing && lastArgs && !config.leading) { func(...lastArgs).catch((e) => console.error("Error in debounced handler:", e), ); } isLeading = true; lastArgs = null; }, config.wait); }; } function throttle( func: (event: Event) => Promise<void>, options: number | { limit: number }, ) { const limit = typeof options === "number" ? options : options.limit; let inThrottle = false; return (event: Event) => { if (!inThrottle) { func(event).catch((e) => console.error("Error in throttled handler:", e)); inThrottle = true; setTimeout(() => (inThrottle = false), limit); } }; } // ==================== SHORTCUTS and UTILITIES ==================== /** * Interface for event shortcut functions that includes the gen property */ interface EventShortcutFunction<K extends keyof HTMLElementEventMap> { <El extends Element>( element: El, handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): CleanupFunction; ( selector: string, handler: HybridEventHandler<HTMLElement, K>, options?: HybridEventOptions, ): CleanupFunction | null; <El extends Element>( handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): ElementFn<El, CleanupFunction>; gen<El extends Element>( handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): Workflow<CleanupFunction>; } /** * @internal * A generic factory to create event shortcut functions like `click`, `input`, etc. * This reduces code duplication and ensures all shortcuts share the same robust logic. */ function createEventShortcut<K extends keyof HTMLElementEventMap>( eventType: K, ): EventShortcutFunction<K> { function shortcut<El extends Element>( element: El, handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): CleanupFunction; function shortcut( selector: string, handler: HybridEventHandler<HTMLElement, K>, options?: HybridEventOptions, ): CleanupFunction | null; function shortcut<El extends Element>( handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): ElementFn<El, CleanupFunction>; function shortcut(...args: any[]): any { if (args[0] instanceof Element) { const [element, handler, options] = args; return on(element, eventType, handler as any, options); } // Check if first arg is a CSS selector (string) with handler as second arg if (typeof args[0] === "string" && args.length >= 2) { const [selector, handler, options] = args; return on(selector, eventType, handler as any, options); } // Generator pattern - handler is first arg const [handler, options] = args; return on(eventType, handler as any, options); } // Add gen property (shortcut as any).gen = function <El extends Element>( handler: HybridEventHandler<El, K>, options?: HybridEventOptions, ): Workflow<CleanupFunction> { return (function* (): Generator< Operation<CleanupFunction>, CleanupFunction, any > { const op: Operation<CleanupFunction> = (ctx: WatchContext) => { const element = ctx.element; if (!element) { throw new Error("Element is undefined in event operation context"); } const enhancedHandler = createEnhancedHandler( element as unknown as HTMLElement, handler as any, true, // inGeneratorContext options || {}, ); const finalHandler = applyTimingModifiers( enhancedHandler, options || {}, ); const cleanup = setupEventListener( element as unknown as HTMLElement, eventType, finalHandler, options || {}, ); return cleanup; }; yield op; return yield op; })(); }; return shortcut as EventShortcutFunction<K>; } /** * Attaches a click event listener using the full dual API pattern. * * This is a convenient shortcut for `on('click', ...)` that provides the complete * dual API functionality. It works with direct elements, CSS selectors, and within * watch generators, supporting advanced features like debouncing, delegation, and * queue management. * * @param element - HTMLElement to attach listener to (direct API) * @param selector - CSS selector to find element (selector API) * @param handler - Event handler function (can be a generator) * @param options - Advanced event options * @returns CleanupFunction when used directly, ElementFn when in generator mode * * @example Pattern 1: Direct element manipulation * ```typescript * import { click } from 'watch-selector'; * * const button = document.getElementById('my-button'); * const cleanup = click(button, (event) => { * console.log('Button clicked!', event.target); * }); * * // Later, clean up the listener * cleanup(); * ``` * * @example Pattern 2: CSS selector manipulation * ```typescript * import { click } from 'watch-selector'; * * // Attach to element found by selector * click('#submit-button', () => { * console.log('Submit button clicked!'); * }); * * // With event options * click('.once-button', (event) => { * console.log('This only fires once'); * }, { once: true }); * ``` * * @example Pattern 3: Traditional generator usage * ```typescript * import { watch, click, addClass } from 'watch-selector'; * * watch('.interactive-button', function* () { * yield click(function* (event) { * yield addClass('clicked'); * console.log('Button clicked at:', event.clientX, event.clientY); * }); * }); * ``` * * @example Pattern 4: Unified yield* pattern with $ wrapper * ```typescript * import { watch, $, click, addClass } from 'watch-selector'; * * watch('.button', async function* () { * yield* $(click(async function* (event) { * yield* $(addClass('processing')); * await processClick(event); * yield* $(removeClass('processing')); * })); * }); * ``` * * @example Pattern 5: Pure generator submodule * ```typescript * import { watch } from 'watch-selector'; * import { click, addClass } from 'watch-selector/generator'; * * watch('.button', async function* () { * yield* click(async function* (event) { * yield* addClass('active'); * // Handle click... * }); * }); * ``` * * @example Advanced options with debouncing * ```typescript * import { watch, click, addClass, removeClass } from 'watch-selector'; * * watch('.rapid-click-btn', function* () { * yield click(function* (event) { * yield addClass('processing'); * // Process click * yield removeClass('processing'); * }, { * debounce: { wait: 300, leading: true, trailing: false }, * queue: 'latest' // Cancel previous processing * }); * }); * ``` * * @example Event delegation for dynamic content * ```typescript * import { click } from 'watch-selector'; * * const container = document.getElementById('dynamic-list'); * click(container, (event, delegatedElement) => { * console.log('Clicked item:', delegatedElement.textContent); * }, { * delegate: '.list-item' // Handle clicks on any .list-item inside container * }); * ``` * * watch('.button', function* () { * yield click(function* (event) { * yield addClass('clicked'); * yield delay(150); * yield removeClass('clicked'); * }, { * debounce: { wait: 300 }, * queue: 'latest' * }); * }); * ``` */ export const click = createEventShortcut("click"); /** * Generator version of click event handler for use with yield*. * * @example Generator usage with yield* * ```typescript * import { watch, click } from 'watch-selector'; * * watch('button', function* () { * yield* click.gen(function* (event) { * yield* addClass('clicked'); * console.log('Button clicked!'); * }); * }); * ``` */ /** * Attaches an input event listener using the dual API pattern. * * This is a convenient shortcut for `on('input', ...)` that's particularly useful * for handling real-time input changes in forms. It supports advanced features * like debouncing, which is commonly needed for search inputs or live validation. * * @param element - HTMLElement to attach listener to (direct API) * @param handler - Event handler function (can be a generator) * @param options - Advanced event options including debouncing * @returns CleanupFunction when used directly, ElementFn when in generator mode * * @example Real-time search with debouncing * ```typescript * import { watch, input, text } from 'watch-selector'; * * watch('#search-input', function* () { * yield input(function* (event) { * const query = (event.target as HTMLInputElement).value; * if (query.length > 2) { * const results = yield* searchAPI(query); * yield* updateResults(results); * } * }, { * debounce: { wait: 300, trailing: true } * }); * }); * ``` * * @example Form validation * ```typescript * import { watch, input, addClass, removeClass, attr } from 'watch-selector'; * * watch('input[required]', function* () { * yield input(function* (event) { * const input = event.target as HTMLInputElement; * const isValid = input.checkValidity(); * * if (isValid) { * yield removeClass('invalid'); * yield addClass('valid'); * yield attr('aria-invalid', 'false'); * } else { * yield removeClass('valid'); * yield addClass('invalid'); * yield attr('aria-invalid', 'true'); * } * }); * }); * ``` * * @example Character counter * ```typescript * import { watch, input, text } from 'watch-selector'; * * watch('.search-input', function* () { * yield input(function* (event) { * const query = (event.target as HTMLInputElement).value; * const results = await searchAPI(query); * yield text(`.results`, `Found ${results.length} results`); * }, { * debounce: { wait: 300 } * }); * }); * ``` * * @example Form validation * ```typescript * import { watch, input, addClass, removeClass } from 'watch-selector'; * * watch('.email-input', function* () { * yield input(function* (event) { * const email = (event.target as HTMLInputElement).value; * const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); * * if (isValid) { * yield addClass('valid'); * yield removeClass('invalid'); * } else { * yield addClass('invalid'); * yield removeClass('valid'); * } * }); * }); * ``` */ export const input = createEventShortcut("input"); /** * Attaches a change event listener using the dual API pattern. * * This is a convenient shortcut for `on('change', ...)` that's ideal for handling * discrete changes in form elements like selects, checkboxes, and radio buttons. * Unlike input events, change events fire when the user finishes changing a value. * * @param element - HTMLElement to attach listener to (direct API) * @param handler - Event handler function (can be a generator) * @param options - Advanced event options * @returns CleanupFunction when used directly, ElementFn when in generator mode * * @example Select dropdown handler * ```typescript * import { watch, change, text, addClass, removeClass } from 'watch-selector'; * * watch('#theme-selector', function* () { * yield change(function* (event) { * const select = event.target as HTMLSelectElement; * const theme = select.value; * * // Update UI based on selection * yield removeClass('theme-light theme-dark theme-auto'); * yield addClass(`theme-${theme}`); * yield text('#current-theme', `Current theme: ${theme}`); * }); * }); * ``` * * @example Checkbox toggle * ```typescript * import { watch, change, toggleClass, attr } from 'watch-selector'; * * watch('#newsletter-checkbox', function* () { * yield change(function* (event) { * const checkbox = event.target as HTMLInputElement; * const isChecked = checkbox.checked; * * yield toggleClass('subscribed', isChecked); * yield attr('aria-pressed', isChecked.toString()); * * // Update related UI * const statusText = isChecked ? 'Subscribed' : 'Not subscribed'; * yield text('#subscription-status', statusText); * }); * }); * ``` * * @example Radio button group * ```typescript * import { watch, change, text, removeClass, addClass } from 'watch-selector'; * * watch('.category-select', function* () { * yield change(function* (event) { * const select = event.target as HTMLSelectElement; * const category = select.value; * yield text('.selected-category', `Selected: ${category}`); * }); * }); * ``` * * @example Checkbox handling * ```typescript * import { watch, change, addClass, removeClass } from 'watch-selector'; * * watch('.feature-toggle', function* () { * yield change(function* (event) { * const checkbox = event.target as HTMLInputElement; * const container = self(); * * if (checkbox.checked) { * yield addClass('feature-enabled'); * yield removeClass('feature-disabled'); * } else { * yield addClass('feature-disabled'); * yield removeClass('feature-enabled'); * } * }); * }); * ``` */ export const change = createEventShortcut("change"); /** * Attaches a submit event listener using the dual API pattern. * * This is a convenient shortcut for `on('submit', ...)` that's specifically designed * for handling form submissions. It automatically provides access to the form element * and is commonly used with preventDefault() to handle submissions via JavaScript. * * @param element - HTMLFormElement to attach listener to (direct API) * @param handler - Event handler function (can be a generator) * @param options - Advanced event options * @returns CleanupFunction when used directly, ElementFn when in generator mode * * @example Form submission with validation * ```typescript * import { watch, submit, addClass, removeClass, text } from 'watch-selector'; * * watch('#contact-form', function* () { * yield submit(function* (event) { * event.preventDefault(); * * const form = event.target as HTMLFormElement; * const formData = new FormData(form); * * // Show loading state * yield addClass('submitting'); * yield text('#submit-btn', 'Sending...'); * * try { * const response = yield* submitForm(formData); * * if (response.ok) { * yield addClass('success'); * yield text('#message', 'Form submitted successfully!'); * form.reset(); * } else { * yield addClass('error'); * yield text('#message', 'Submission failed. Please try again.'); * } * } catch (error) { * yield addClass('error'); * yield text('#message', 'Network error. Please check your connection.'); * } finally { * yield removeClass('submitting'); * yield text('#submit-btn', 'Submit'); * } * }); * }); * ``` * * @example Form validation before submission * ```typescript * import { watch, submit, hasClass, text } from 'watch-selector'; * * watch('form', function* () { * yield submit(function* (event) { * const form = event.target as HTMLFormElement; * const hasErrors = yield hasClass('has-validation-errors'); * * if (hasErrors) { * event.preventDefault(); * yield text('.error-message', 'Please fix validation errors before submitting'); * return; * } * * // Allow form to submit normally or handle with AJAX * yield text('.status', 'Submitting form...'); * }); * }); * ``` * * @example Multi-step form submission * ```typescript * import { watch, submit, getState, setState, addClass, removeClass } from 'watch-selector'; * * watch('.contact-form', function* () { * yield submit(function* (event) { * event.preventDefault(); * * const form = event.target as HTMLFormElement; * const formData = new FormData(form); * * // Show loading state * yield addClass('loading'); * yield text('.submit-btn', 'Submitting...'); * * try { * const response = await fetch('/api/contact', { * method: 'POST', * body: formData * }); * * if (response.ok) { * yield text('.message', 'Form submitted successfully!'); * form.reset(); * } else { * yield text('.message', 'Submission failed. Please try again.'); * } * } catch (error) { * yield text('.message', 'Network error. Please try again.'); * } finally { * yield removeClass('loading'); * yield text('.submit-btn', 'Submit'); * } * }); * }); * ``` * * @example Multi-step form * ```typescript * import { watch, submit, getState, setState } from 'watch-selector'; * * watch('.multi-step-form', function* () { * setState('currentStep', 1); * * yield submit(function* (event) { * event.preventDefault(); * * const currentStep = getState<number>('currentStep'); * const form = event.target as HTMLFormElement; * * if (currentStep < 3) { * // Validate current step and advance * setState('currentStep', currentStep + 1); * yield showStep(currentStep + 1); * } else { * // Final submission * yield submitForm(form); * } * }); * }); * ``` */ export const submit = createEventShortcut("submit"); /** * Creates a reusable event behavior that can be yielded within a `watch` generator. * This is useful for encapsulating complex or repeated event logic. * * @example * const rippleEffect = createEventBehavior('click', function*() { * yield addClass('ripple'); * yield delay(500); * yield removeClass('ripple'); * }); * * watch('.material-button', function*() { * yield* rippleEffect(); * }); */ export function createEventBehavior< K extends keyof HTMLElementEventMap, T = any, >( eventType: K | string, behavior: | HybridEventHandler<Element, K> | HybridCustomEventHandler<Element, T>, options?: HybridEventOptions, ): () => Generator<ElementFn<Element, CleanupFunction>, void, unknown> { return function* () { yield on( eventType as K, behavior as HybridEventHandler<Element, K>, options, ); }; } /** * Composes multiple event handlers into a single handler. The handlers are executed * in the order they are provided. This is useful for layering multiple pieces of * logic onto a single event. * * @example * const logClick = (event) => console.log('Clicked!'); * const trackClick = (event) => analytics.track('click'); * const composedHandler = composeEventHandlers(logClick, trackClick); * yield click(composedHandler); */ export function composeEventHandlers<K extends keyof HTMLElementEventMap>( ...handlers: HybridEventHandler<Element, K>[] ): HybridEventHandler<Element, K> { return async function (event: HTMLElementEventMap[K], element?: Element) { for (const handler of handlers) { const result = (handler as Function)(event, element); if (result && typeof result.next === "function") { // It's a generator - execute it if (Symbol.asyncIterator in result) { // Async generator for await (const _ of result) { // Just iterate through it } } else { // Sync generator for (const _ of result) { // Just iterate through it } } } else if (result && typeof result.then === "function") { // It's a promise await result; } // If result is undefined or something else, just continue } }; } /** * A helper for creating a delegated event listener. This is a convenient alternative * to using the `delegate` option in `on()`. * * @example * // These two are equivalent: * yield delegate('.list-item', 'click', handler); * yield on('click', handler, { delegate: '.list-item' }); * * @param selector The CSS selector for child elements to target. * @param eventType The name of the event to listen for. * @param handler The function to call when the event occurs on a matching child. * @param options Additional event listener options. */ export function delegate<K extends keyof HTMLElementEventMap, T = any>( selector: string, eventType: K | string, handler: | HybridEventHandler<Element, K> | HybridCustomEventHandler<Element, T>, options?: Omit<HybridEventOptions, "delegate">, ): ElementFn<Element, CleanupFunction> { return on(eventType as K, handler as HybridEventHandler<Element, K>, { ...options, delegate: selector, }); } /** * Creates a new `CustomEvent` with full type safety for the `detail` payload. * * @param type The name of the custom event. * @param detail The data payload to include with the event. * @param options Standard `EventInit` options. * @returns A new, typed `CustomEvent` instance. */ export function createCustomEvent<T = any>( type: string, detail: T, options?: EventInit, ): CustomEvent<T> { return new CustomEvent(type, { detail, bubbles: true, cancelable: true, ...options, }); } /** * Dispatches a `CustomEvent` from an element. * * @example * // Inside a generator: * yield emit('user:action', { action: 'save' }); * * @example * // Standalone: * emit(document.body, 'app:ready'); */ export function emit<El extends Element>( element: El, eventName: string, detail?: any, options?: EventInit, ): void; export function emit<El extends Element = HTMLElement>( eventName: string, detail?: any, options?: EventInit, ): ElementFn<El>; export function emit(...args: any[]): any { if (args[0] instanceof Element) { const [element, eventName, detail, options] = args; element.dispatchEvent(createCustomEvent(eventName, detail, options)); } else { const [eventName, detail, options] = args; return (element: Element) => element.dispatchEvent(createCustomEvent(eventName, detail, options)); } } /** * Generator version of emit for use with yield*. */ emit.gen = function <T = any>( eventTypeOrEvent: string | CustomEvent<T>, detail?: T, options?: EventInit, ): Workflow<void> { return (function* (): Generator<Operation<void>, void, any> { const op: Operation<void> = (ctx: WatchContext) => { const element = ctx.element; if (!element) { throw new Error("Element is undefined in emit operation context"); } let event: CustomEvent<T>; if (typeof eventTypeOrEvent === "string") { event = new CustomEvent(eventTypeOrEvent, { detail: detail as T, ...options, }); } else { event = eventTypeOrEvent; } element.dispatchEvent(event); }; yield op; })(); }; // ==================== OBSERVER-BASED EVENTS ==================== // Unused helper function - kept for potential future use // function createObserverEvent<T, O, C>( // ObserverClass: new ( // cb: (entries: T[]) => void, // opts?: O, // ) => { observe: (el: Element, opts?: any) => void; disconnect: () => void }, // getChangeData: (entry: T, element: Element) => C, // ) { // function observe( // element: Element, // handler: (change: C) => void, // options?: O, // ): CleanupFunction; // function observe( // selector: string, // handler: (change: C) => void, // options?: O, // ): CleanupFunction | null; // function observe( // handler: (change: C) => void, // options?: O, // ): ElementFn<Element, CleanupFunction>; // function observe(...args: any[]): any { // const setup = ( // element: Element, // handler: (change: C) => void, // options?: O, // ) => { // const observer = new ObserverClass((entries) => { // for (const entry of entries) { // handler(getChangeData(entry, element)); // } // }, options); // observer.observe(element, options); // return () => observer.disconnect(); // }; // if (args[0] instanceof Element) return setup(args[0], args[1], args[2]); // return (element: Element) => setup(element, args[0], args[1]); // } // return observe; // } /** Listens for changes to an element's attributes. */ export function onAttr( element: Element, handler: ( change: AttributeChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: MutationObserverInit, ): CleanupFunction; export function onAttr( selector: string, handler: ( change: AttributeChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: MutationObserverInit, ): CleanupFunction | null; export function onAttr( handler: ( change: AttributeChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: MutationObserverInit, ): ElementFn<Element, CleanupFunction>; // Workflow overload for yield* usage export function onAttr( handler: ( change: AttributeChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: MutationObserverInit, ): Workflow<CleanupFunction>; export function onAttr(...args: any[]): any { const executeHandler = async ( element: Element, handler: any, change: AttributeChange & { element: Element }, ) => { const result = handler(change); // Handle async generator if ( result && typeof result === "object" && Symbol.asyncIterator in result ) { const { runOn } = await import("../watch"); await runOn(element as HTMLElement, () => result); } // Handle sync generator else if ( result && typeof result === "object" && Symbol.iterator in result ) { const context = getCurrentContext(); if (context) { const gen = result as Generator<any, void, any>; let genResult = gen.next(); while (!genResult.done) { if (typeof genResult.value === "function") { genResult.value(context); } genResult = gen.next(); } } } // Handle promise else if (result && typeof result.then === "function") { await result; } }; const setup = ( element: Element, handler: any, options?: MutationObserverInit, ) => { const observer = new MutationObserver((entries) => { for (const entry of entries) { executeHandler(element, handler, { attributeName: entry.attributeName!, oldValue: entry.oldValue, newValue: element.getAttribute(entry.attributeName!), element, }); } }); observer.observe(element, { attributes: true, attributeOldValue: true, ...options, }); return () => observer.disconnect(); }; // Direct element pattern if (args[0] instanceof Element) return setup(args[0], args[1], args[2]); // CSS selector pattern if (typeof args[0] === "string" && typeof args[1] === "function") { const [selector, handler, options] = args; const elements = document.querySelectorAll(selector); const cleanups: CleanupFunction[] = []; elements.forEach((element) => { cleanups.push(setup(element, handler, options)); }); return cleanups.length > 0 ? () => cleanups.forEach((cleanup) => cleanup()) : null; } // Check if we're in a generator context and need to return a Workflow const context = getCurrentContext(); const isWorkflowUsage = context && !args[0]?.nodeType && // Not a direct element typeof args[0] === "function" && // Handler function args.length <= 2; // Not selector usage if (isWorkflowUsage) { // Return a Workflow for yield* usage return (function* (): Generator< Operation<CleanupFunction>, CleanupFunction, any > { const op: Operation<CleanupFunction> = (ctx: WatchContext) => { const element = ctx.element; if (!element) { throw new Error("Element is undefined in onAttr operation context"); } const handler = args[0]; const options = args[1] || {}; const cleanup = setup(element, handler, options); createCleanupFunction(element as unknown as HTMLElement)(cleanup); return cleanup; }; const cleanup = yield op; return cleanup; })(); } // Generator pattern return (element: Element) => setup(element, args[0], args[1]); } /** Listens for changes to an element's `textContent`. */ export function onText( element: Element, handler: ( change: TextChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: MutationObserverInit, ): CleanupFunction; export function onText( selector: string, handler: ( change: TextChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: MutationObserverInit, ): CleanupFunction | null; export function onText( handler: ( change: TextChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: MutationObserverInit, ): ElementFn<Element, CleanupFunction>; // Workflow overload for yield* usage export function onText( handler: ( change: TextChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: MutationObserverInit, ): Workflow<CleanupFunction>; export function onText(...args: any[]): any { const executeHandler = async ( element: Element, handler: any, change: TextChange & { element: Element }, ) => { const result = handler(change); // Handle async generator if ( result && typeof result === "object" && Symbol.asyncIterator in result ) { const { runOn } = await import("../watch"); await runOn(element as HTMLElement, () => result); } // Handle sync generator else if ( result && typeof result === "object" && Symbol.iterator in result ) { const context = getCurrentContext(); if (context) { const gen = result as Generator<any, void, any>; let genResult = gen.next(); while (!genResult.done) { if (typeof genResult.value === "function") { genResult.value(context); } genResult = gen.next(); } } } // Handle promise else if (result && typeof result.then === "function") { await result; } }; const setup = ( element: Element, handler: any, options?: MutationObserverInit, ) => { let oldText = element.textContent || ""; const observer = new MutationObserver((_entries) => { const newText = element.textContent || ""; if (oldText !== newText) { executeHandler(element, handler, { oldText, newText, element, }); oldText = newText; } }); observer.observe(element, { characterData: true, childList: true, subtree: true, characterDataOldValue: true, ...options, }); return () => observer.disconnect(); }; // Direct element pattern if (args[0] instanceof Element) return setup(args[0], args[1], args[2]); // CSS selector pattern if (typeof args[0] === "string" && typeof args[1] === "function") { const [selector, handler, options] = args; const elements = document.querySelectorAll(selector); const cleanups: CleanupFunction[] = []; elements.forEach((element) => { cleanups.push(setup(element, handler, options)); }); return cleanups.length > 0 ? () => cleanups.forEach((cleanup) => cleanup()) : null; } // Generator pattern return (element: Element) => setup(element, args[0], args[1]); } /** Listens for when an element becomes visible or hidden in the viewport. */ export function onVisible( element: Element, handler: ( change: VisibilityChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: IntersectionObserverInit, ): CleanupFunction; export function onVisible( selector: string, handler: ( change: VisibilityChange & { element: Element }, ) => | void | Promise<void> | Generator<any, void, any> | AsyncGenerator<any, void, any>, options?: IntersectionObserverInit, )