watch-selector
Version:
Runs a function when a selector is added to dom
1,664 lines (1,569 loc) • 71.5 kB
text/typescript
/**
* @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,
)