UNPKG

watch-selector

Version:

Runs a function when a selector is added to dom

605 lines (549 loc) 15.8 kB
/** * @module fluent/generator * * Fluent API with generator support for yield* patterns. * Provides a chainable interface that returns async generators (Workflows). */ import type { ElementFromSelector } from "../types"; /** * Workflow type for async generator functions that can be used with yield*. */ export type Workflow<T = void> = AsyncGenerator< (element: Element) => any, T, unknown >; /** * FluentGeneratorSelector provides a chainable API that returns async generators. * Each method returns an async generator that can be used with yield* for clean composition. * * @example * ```typescript * import { watch } from 'watch-selector'; * import { gen } from 'watch-selector/fluent/generator'; * * watch('.card', async function* () { * // Chain operations with yield* * yield* gen() * .addClass('active') * .text('Updated') * .style({ backgroundColor: 'blue' }) * .flow(); * }); * ``` */ export class FluentGeneratorSelector<El extends Element = Element> { private operations: Array<(element: El) => void | any> = []; /** * Adds a text operation to the chain. */ text(content: string | number): FluentGeneratorSelector<El> { this.operations.push((element) => { element.textContent = String(content); }); return this; } /** * Adds an HTML operation to the chain. */ html(content: string): FluentGeneratorSelector<El> { this.operations.push((element) => { if (element instanceof HTMLElement) { // WARNING: Only use with trusted content. Consider using textContent for user input. element.innerHTML = content; } }); return this; } /** * Adds a class addition operation to the chain. */ addClass(...classes: string[]): FluentGeneratorSelector<El> { this.operations.push((element) => { element.classList.add(...classes); }); return this; } /** * Adds a class removal operation to the chain. */ removeClass(...classes: string[]): FluentGeneratorSelector<El> { this.operations.push((element) => { element.classList.remove(...classes); }); return this; } /** * Adds a class toggle operation to the chain. */ toggleClass(className: string, force?: boolean): FluentGeneratorSelector<El> { this.operations.push((element) => { return element.classList.toggle(className, force); }); return this; } /** * Adds a style operation to the chain. */ style(styles: Partial<CSSStyleDeclaration>): FluentGeneratorSelector<El> { this.operations.push((element) => { if (element instanceof HTMLElement) { Object.assign(element.style, styles); } }); return this; } /** * Adds an attribute operation to the chain. */ attr( name: string, value: string | number | boolean, ): FluentGeneratorSelector<El> { this.operations.push((element) => { element.setAttribute(name, String(value)); }); return this; } /** * Adds an attribute removal operation to the chain. */ removeAttr(name: string): FluentGeneratorSelector<El> { this.operations.push((element) => { element.removeAttribute(name); }); return this; } /** * Adds a property operation to the chain. */ prop<K extends keyof El>(name: K, value: El[K]): FluentGeneratorSelector<El> { this.operations.push((element) => { (element as any)[name] = value; }); return this; } /** * Adds a data attribute operation to the chain. */ data(key: string, value: any): FluentGeneratorSelector<El> { this.operations.push((element) => { if (element instanceof HTMLElement) { element.dataset[key] = String(value); } }); return this; } /** * Adds a value operation to the chain (for form elements). */ val(value: string | number): FluentGeneratorSelector<El> { this.operations.push((element) => { if ("value" in element) { (element as any).value = String(value); } }); return this; } /** * Adds a checked operation to the chain (for checkboxes/radios). */ checked(state: boolean): FluentGeneratorSelector<El> { this.operations.push((element) => { if ("checked" in element) { (element as any).checked = state; } }); return this; } /** * Adds a show operation to the chain. */ show(): FluentGeneratorSelector<El> { this.operations.push((element) => { if (element instanceof HTMLElement) { element.style.display = ""; } }); return this; } /** * Adds a hide operation to the chain. */ hide(): FluentGeneratorSelector<El> { this.operations.push((element) => { if (element instanceof HTMLElement) { element.style.display = "none"; } }); return this; } /** * Adds a focus operation to the chain. */ focus(): FluentGeneratorSelector<El> { this.operations.push((element) => { if (element instanceof HTMLElement) { element.focus(); } }); return this; } /** * Adds a blur operation to the chain. */ blur(): FluentGeneratorSelector<El> { this.operations.push((element) => { if (element instanceof HTMLElement) { element.blur(); } }); return this; } /** * Adds a click handler to the chain. */ click(handler: (event: MouseEvent) => void): FluentGeneratorSelector<El> { this.operations.push((element) => { element.addEventListener("click", handler as EventListener); }); return this; } /** * Adds a generic event listener to the chain. */ on(event: string, handler: EventListener): FluentGeneratorSelector<El> { this.operations.push((element) => { element.addEventListener(event, handler); }); return this; } /** * Executes all chained operations as an async generator. * This is the method that returns the Workflow for use with yield*. * * @returns Async generator that executes all operations when yielded with yield* * * @example * ```typescript * watch('.button', async function* () { * yield* gen() * .addClass('ready') * .text('Click me!') * .flow(); * }); * ``` */ async *flow(): Workflow<void> { // Yield a function that executes all operations yield async (element: Element) => { for (const operation of this.operations) { await operation(element as El); } }; } /** * Executes operations and returns a value from the element. * * @param getter - Function to extract a value from the element * @returns Async generator that returns the extracted value * * @example * ```typescript * watch('.input', async function* () { * const value = yield* gen() * .addClass('active') * .flowReturn(el => (el as HTMLInputElement).value); * }); * ``` */ async *flowReturn<T>(getter: (element: El) => T): Workflow<T> { let result: T; yield (element: Element) => { // Execute all operations for (const operation of this.operations) { operation(element as El); } // Get the return value result = getter(element as El); return result; }; return result!; } /** * Conditionally executes the chain based on a predicate. * * @param condition - Function that returns true to execute the chain * @returns FluentGeneratorSelector for continued chaining * * @example * ```typescript * watch('.item', async function* () { * yield* gen() * .if(el => !el.classList.contains('processed')) * .addClass('processed') * .text('Done') * .flow(); * }); * ``` */ if(condition: (element: El) => boolean): FluentGeneratorSelector<El> { const previousOps = [...this.operations]; // Clear operations to collect subsequent ones this.operations = []; // Store operations that will be added after if() const conditionalOps: Array<(element: El) => void | any> = []; // Create a proxy to capture operations meant to be conditional const proxy = new Proxy(this, { get(target, prop, receiver) { const original = Reflect.get(target, prop, receiver); // For methods that add operations, capture them for conditional execution if ( typeof original === "function" && prop !== "flow" && prop !== "flowReturn" && prop !== "if" && prop !== "find" ) { return function (...args: any[]) { original.apply(target, args); // Capture the last added operation for conditional execution if (target.operations.length > 0) { conditionalOps.push( target.operations[target.operations.length - 1], ); } return receiver; }; } // For flow methods, apply the conditional logic if (prop === "flow" || prop === "flowReturn") { return function (...args: any[]) { // Replace operations with the conditional operation target.operations = [ async (element: El) => { // First run previous operations for (const op of previousOps) { await op(element); } // Then conditionally run subsequent operations if (condition(element)) { for (const conditionalOp of conditionalOps) { await conditionalOp(element); } } }, ]; // Call the original flow method return (original as any).apply(target, args); }; } return original; }, }); return proxy as FluentGeneratorSelector<El>; } /** * Applies operations to child elements matching a selector. * * @param selector - CSS selector for child elements * @returns FluentGeneratorSelector for continued chaining * * @example * ```typescript * watch('.container', async function* () { * yield* gen() * .find('.item') * .addClass('found') * .flow(); * }); * ``` */ find(selector: string): FluentGeneratorSelector<El> { const previousOps = [...this.operations]; // Clear operations and replace with a new one that handles children this.operations = []; // Store operations that will be added after find() const childOps: Array<(element: El) => void | any> = []; // Create a proxy to capture operations meant for children const proxy = new Proxy(this, { get(target, prop, receiver) { const original = Reflect.get(target, prop, receiver); // For methods that add operations, capture them for children if ( typeof original === "function" && prop !== "flow" && prop !== "flowReturn" ) { return function (...args: any[]) { original.apply(target, args); // Capture the last added operation for children if (target.operations.length > 0) { childOps.push(target.operations[target.operations.length - 1]); } return receiver; }; } // For flow methods, apply the find logic if (prop === "flow" || prop === "flowReturn") { return function (...args: any[]) { // Replace operations with the find operation target.operations = [ async (element: Element) => { // First run previous operations on the parent for (const op of previousOps) { await op(element as El); } // Then find children and apply child operations const children = element.querySelectorAll(selector); children.forEach((child) => { for (const childOp of childOps) { childOp(child as El); } }); }, ]; // Call the original flow method return (original as any).apply(target, args); }; } return original; }, }); return proxy as FluentGeneratorSelector<El>; } /** * Delays execution for a specified time. * * @param ms - Milliseconds to delay * @returns FluentGeneratorSelector for continued chaining * * @example * ```typescript * watch('.animated', async function* () { * yield* gen() * .addClass('fade-in') * .delay(300) * .removeClass('fade-in') * .flow(); * }); * ``` */ delay(ms: number): FluentGeneratorSelector<El> { this.operations.push(() => { return new Promise((resolve) => setTimeout(resolve, ms)); }); return this; } } /** * Creates a new fluent generator chain. * * @returns A new FluentGeneratorSelector instance * * @example * ```typescript * import { watch } from 'watch-selector'; * import { gen } from 'watch-selector/fluent/generator'; * * watch('.card', async function* () { * yield* gen() * .addClass('active') * .text('Active Card') * .flow(); * }); * ``` */ export function gen< El extends Element = Element, >(): FluentGeneratorSelector<El> { return new FluentGeneratorSelector<El>(); } /** * Creates a fluent generator chain with type inference from selector. * * @param _selector - CSS selector (used only for type inference) * @returns A new FluentGeneratorSelector with inferred element type * * @example * ```typescript * watch('button', async function* () { * yield* genFor('button') * .prop('disabled', false) * .text('Enabled') * .flow(); * }); * ``` */ export function genFor<S extends string>( _selector: S, ): FluentGeneratorSelector<ElementFromSelector<S>> { return new FluentGeneratorSelector<ElementFromSelector<S>>(); } /** * Combines multiple generator workflows into a single workflow. * * @param workflows - Array of workflows to combine * @returns Combined workflow that executes all in sequence * * @example * ```typescript * import { setTextFlow, addClassFlow } from 'watch-selector/explicit/generator-support'; * * watch('.status', async function* () { * yield* combine([ * setTextFlow('Loading...'), * addClassFlow('loading'), * delayFlow(1000), * setTextFlow('Ready'), * removeClassFlow('loading') * ]); * }); * ``` */ export async function* combine<T = void>( workflows: Array<Workflow<any>>, ): Workflow<T> { let lastResult: any; for (const workflow of workflows) { // Process each workflow for await (const operation of workflow) { lastResult = yield operation; } } return lastResult; } /** * Executes a workflow conditionally. * * @param condition - Condition to check * @param workflow - Workflow to execute if condition is true * @returns Workflow that conditionally executes * * @example * ```typescript * watch('.button', async function* () { * const isActive = yield* hasClassFlow('active'); * yield* when(!isActive, addClassFlow('inactive')); * }); * ``` */ export async function* when<T = void>( condition: boolean | (() => boolean), workflow: Workflow<T>, ): Workflow<T | undefined> { const shouldExecute = typeof condition === "function" ? condition() : condition; if (shouldExecute) { let result: T | undefined; for await (const operation of workflow) { result = (yield operation) as T; } return result; } return undefined; } /** * Alias for gen() with jQuery-like syntax. */ export const $gen = gen;