UNPKG

watch-selector

Version:

Runs a function when a selector is added to dom

1,801 lines (1,479 loc) โ€ข 98.3 kB
[![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) [![Deno](https://img.shields.io/badge/Deno-Compatible-green.svg)](https://deno.land/) [![JSR](https://img.shields.io/badge/JSR-Published-orange.svg)](https://jsr.io/) # Watch ๐Ÿ•ถ๏ธ **A type-safe DOM observation library that keeps your JavaScript working when the HTML changes.** Ever tried adding interactivity to a server-rendered site? You write event listeners, but then the DOM updates and your JavaScript stops working. Or you need different behavior for each instance of an element, but managing that state gets messy fast. Watch solves this by letting you attach persistent behaviors to CSS selectors. When new elements match your selector, they automatically get the behavior. When they're removed, everything cleans up automatically. **Perfect for:** Server-rendered sites, Chrome extensions, e-commerce templates, htmx apps, and anywhere you don't control the markup. ## The Problem Watch Solves Traditional DOM manipulation breaks when content changes: ```typescript // โŒ This stops working when buttons are re-rendered document.querySelectorAll('button').forEach(btn => { let clicks = 0; btn.addEventListener('click', () => { clicks++; // State is lost if button is removed/added btn.textContent = `Clicked ${clicks} times`; }); }); ``` Server-rendered sites, Chrome extensions, and dynamic content make this worse. You need: - **Persistent behavior** that survives DOM changes - **Instance-specific state** for each element - **Automatic cleanup** to prevent memory leaks - **Type safety** so you know what elements you're working with Watch handles all of this automatically. <br> ## Table of Contents - [Quick Start](#quick-start) - [Why Choose Watch?](#why-choose-watch) - [Installation](#installation) - [Core Concepts](#core-concepts) - [Real-World Examples](#real-world-examples) - [Advanced Features](#advanced-features) - [Advanced Composition: Controllers & Behavior Layering](#advanced-composition-controllers--behavior-layering) - [Component Composition: Building Hierarchies](#component-composition-building-hierarchies) - [Building Higher-Level Abstractions](#building-higher-level-abstractions) - [Scoped Watch: Isolated DOM Observation](#scoped-watch-isolated-dom-observation) - [Complete API Reference](#complete-api-reference) - [Performance & Browser Support](#performance--browser-support) - [Frequently Asked Questions](#frequently-asked-questions) - [License](#license) <br> ## Quick Start ```typescript import { watch, click, text } from 'watch-selector'; // Make all buttons interactive watch('button', function* () { yield click(() => { yield text('Button clicked!'); }); }); // Each button gets its own click counter watch('.counter-btn', function* () { let count = 0; yield click(() => { count++; yield text(`Clicked ${count} times`); }); }); ``` That's it! Watch handles all the DOM observation, state management, and cleanup automatically. <br> ## Why Choose Watch? ### ๐Ÿ” **Persistent Element Behavior** Your code keeps working even when the DOM changes: ```typescript // Traditional approach breaks when elements are added/removed document.querySelectorAll('button').forEach(btn => { btn.addEventListener('click', handler); // Lost if button is re-rendered }); // Watch approach persists automatically watch('button', function* () { yield click(handler); // Works for all buttons, present and future }); ``` ### ๐ŸŽฏ **Type-Safe by Design** TypeScript knows what element you're working with: ```typescript watch('input[type="email"]', function* () { // TypeScript knows this is HTMLInputElement yield on('blur', () => { if (!self().value.includes('@')) { // .value is typed yield addClass('error'); } }); }); ``` ### ๐Ÿง  **Element-Scoped State** Each element gets its own isolated state: ```typescript watch('.counter', function* () { let count = 0; // This variable is unique to each counter element yield click(() => { count++; // Each counter maintains its own count yield text(`Count: ${count}`); }); }); ``` ### ๐Ÿ”„ **Works Both Ways** Functions work directly on elements and in generators: ```typescript // Direct usage const button = document.querySelector('button'); text(button, 'Hello'); // Generator usage watch('button', function* () { yield text('Hello'); }); ``` ### โšก **High Performance** - Single global observer for the entire application - Efficient batch processing of DOM changes - Automatic cleanup prevents memory leaks - Minimal memory footprint with WeakMap storage <br> ## Installation ### npm/pnpm/yarn ```bash npm install watch-selector ``` ### ESM CDN (no build required) ```typescript import { watch } from 'https://esm.sh/watch-selector'; // Start using immediately watch('button', function* () { yield click(() => console.log('Hello from CDN!')); }); ``` <br> ## Core Concepts ### Watchers Observe DOM elements and run generators when they appear: ```typescript const controller = watch('selector', function* () { // This runs for each matching element yield elementFunction; }); ``` ### Generators & Yield Generators create persistent contexts that survive DOM changes: ```typescript watch('.component', function* () { let state = 0; // Persists for each element's lifetime yield click(() => { state++; // State is maintained across events yield text(`State: ${state}`); }); // Cleanup happens automatically when element is removed }); ``` **Why generators?** They provide: - **Persistent execution context** that lives with the element - **Declarative behavior** through yield statements - **Automatic cleanup** when elements are removed - **Composable patterns** for building complex behaviors ### Element Context Access the current element and its state: ```typescript watch('.counter', function* () { const counter = createState('count', 0); const element = self(); // Get current element yield click(() => { counter.update(c => c + 1); yield text(`Count: ${counter.get()}`); }); }); ``` ### State Management Type-safe, element-scoped reactive state: ```typescript const counter = createState('count', 0); const doubled = createComputed(() => counter.get() * 2, ['count']); watchState('count', (newVal, oldVal) => { console.log(`${oldVal} โ†’ ${newVal}`); }); ``` <br> ## Real-World Examples ### E-commerce Product Cards ```typescript watch('.product-card', function* () { const inCart = createState('inCart', false); yield click('.add-to-cart', () => { inCart.set(true); yield text('.add-to-cart', 'Added to Cart!'); yield addClass('in-cart'); }); yield click('.remove-from-cart', () => { inCart.set(false); yield text('.add-to-cart', 'Add to Cart'); yield removeClass('in-cart'); }); }); ``` ### Form Validation ```typescript watch('input[required]', function* () { yield on('blur', () => { if (!self().value.trim()) { yield addClass('error'); yield text('.error-message', 'This field is required'); } else { yield removeClass('error'); yield text('.error-message', ''); } }); }); ``` ### Dynamic Content Loading ```typescript watch('.lazy-content', async function* () { yield text('Loading...'); yield onVisible(async () => { const response = await fetch(self().dataset.url); const html = await response.text(); yield html(html); }); }); ``` <br> ## Advanced Features ### Advanced Composition: Controllers & Behavior Layering Watch v1 introduces **WatchController** objects that transform the traditional fire-and-forget watch operations into managed, extensible systems. Controllers enable **Behavior Layering** - the ability to add multiple independent behaviors to the same set of elements. ### **WatchController Fundamentals** Every `watch()` call now returns a `WatchController` instead of a simple cleanup function: ```typescript import { watch, layer, getInstances, destroy } from 'watch-selector'; // Basic controller usage const cardController = watch('.product-card', function* () { // Core business logic const inCart = createState('inCart', false); yield on('click', '.add-btn', () => inCart.set(true)); yield text('Add to Cart'); }); // The controller provides a handle to the watch operation console.log(`Watching ${cardController.getInstances().size} product cards`); ``` ### **Behavior Layering** Add multiple independent behaviors to the same elements: ```typescript // Layer 1: Core functionality const cardController = watch('.product-card', function* () { const inCart = createState('inCart', false); yield on('click', '.add-btn', () => inCart.set(true)); }); // Layer 2: Analytics (added later, different module) cardController.layer(function* () { yield onVisible(() => analytics.track('product-view', { id: self().dataset.productId })); }); // Layer 3: Animations (added conditionally) if (enableAnimations) { cardController.layer(function* () { yield watchState('inCart', (inCart) => { if (inCart) { yield addClass('animate-add-to-cart'); } }); }); } ``` ### **Dual API: Methods vs Functions** Controllers support both object-oriented and functional patterns: ```typescript // Method-based (OOP style) const controller = watch('.component', baseGenerator); controller.layer(enhancementGenerator); controller.layer(analyticsGenerator); const instances = controller.getInstances(); controller.destroy(); // Function-based (FP style) const controller = watch('.component', baseGenerator); layer(controller, enhancementGenerator); layer(controller, analyticsGenerator); const instances = getInstances(controller); destroy(controller); ``` ### **Instance Introspection** Controllers provide read-only access to managed instances: ```typescript const controller = watch('button', function* () { const clickCount = createState('clicks', 0); yield click(() => clickCount.update(n => n + 1)); }); // Inspect all managed instances const instances = controller.getInstances(); instances.forEach((instance, element) => { console.log(`Button ${element.id}:`, instance.getState()); }); // State is read-only from the outside const buttonState = instances.get(someButton)?.getState(); // { clicks: 5 } - snapshot of current state ``` ### **Real-World Example: Composable Product Cards** This example demonstrates how behavior layering enables clean separation of concerns: ```typescript // --- Core product card functionality --- // File: components/product-card.ts export const productController = watch('.product-card', function* () { const inCart = createState('inCart', false); const quantity = createState('quantity', 1); yield on('click', '.add-btn', () => { inCart.set(true); // Update cart through global state or API }); yield on('click', '.quantity-btn', (e) => { const delta = e.target.dataset.delta; quantity.update(q => Math.max(1, q + parseInt(delta))); }); }); // --- Analytics layer --- // File: analytics/product-tracking.ts import { productController } from '../components/product-card'; productController.layer(function* () { // Track product views yield onVisible(() => { analytics.track('product_viewed', { product_id: self().dataset.productId, category: self().dataset.category }); }); // Track cart additions yield watchState('inCart', (inCart, wasInCart) => { if (inCart && !wasInCart) { analytics.track('product_added_to_cart', { product_id: self().dataset.productId, quantity: getState('quantity') }); } }); }); // --- Animation layer --- // File: animations/product-animations.ts import { productController } from '../components/product-card'; productController.layer(function* () { // Animate cart additions yield watchState('inCart', (inCart) => { if (inCart) { yield addClass('animate-add-to-cart'); yield delay(300); yield removeClass('animate-add-to-cart'); } }); // Hover effects yield on('mouseenter', () => yield addClass('hover-highlight')); yield on('mouseleave', () => yield removeClass('hover-highlight')); }); // --- Usage in main application --- // File: main.ts import './components/product-card'; import './analytics/product-tracking'; import './animations/product-animations'; // All layers are automatically active // Analytics and animations are completely independent // Each can be enabled/disabled or modified without affecting others ``` ### **State Communication Between Layers** Layers communicate through shared element state: ```typescript // Layer 1: Set up shared state const formController = watch('form', function* () { const isValid = createState('isValid', false); const errors = createState('errors', []); yield on('input', () => { const validation = validateForm(self()); isValid.set(validation.isValid); errors.set(validation.errors); }); }); // Layer 2: React to validation state formController.layer(function* () { yield watchState('isValid', (valid) => { yield toggleClass('form-invalid', !valid); }); yield watchState('errors', (errors) => { yield text('.error-display', errors.join(', ')); }); }); // Layer 3: Conditional behavior based on state formController.layer(function* () { yield on('submit', (e) => { if (!getState('isValid')) { e.preventDefault(); yield addClass('shake-animation'); } }); }); ``` ### **Controller Lifecycle Management** Controllers are singleton instances per target - calling `watch()` multiple times with the same selector returns the same controller: ```typescript // These all return the same controller instance const controller1 = watch('.my-component', generator1); const controller2 = watch('.my-component', generator2); // Same controller! const controller3 = watch('.my-component', generator3); // Same controller! // All generators are layered onto the same controller console.log(controller1 === controller2); // true console.log(controller1 === controller3); // true // Clean up destroys all layers controller1.destroy(); // Removes all behaviors for '.my-component' ``` ### **Integration with Scoped Utilities** Controllers work seamlessly with scoped watchers: ```typescript // Create a scoped controller const container = document.querySelector('#dashboard'); const scopedController = scopedWatchWithController(container, '.widget', function* () { yield addClass('widget-base'); }); // Layer additional behaviors on the scoped controller scopedController.controller.layer(function* () { yield addClass('widget-enhanced'); yield on('click', () => console.log('Scoped widget clicked')); }); // Inspect scoped instances const scopedInstances = scopedController.controller.getInstances(); console.log(`Managing ${scopedInstances.size} widgets in container`); ``` <br> ### Component Composition: Building Hierarchies Watch supports full parent-child component communication, allowing you to build complex, nested, and encapsulated UIs with reactive relationships. ### Child-to-Parent: Exposing APIs with `createChildWatcher` A child component can `return` an API from its generator. The parent can then use `createChildWatcher` to get a live collection of these APIs. **Child Component** ```typescript // Counter button that exposes an API function* counterButton() { let count = 0; // Set initial text and handle clicks yield text(`Count: ${count}`); yield click(() => { count++; yield text(`Count: ${count}`); }); // Define and return a public API return { getCount: () => count, reset: () => { count = 0; yield text(`Count: ${count}`); console.log(`Button ${self().id} was reset.`); }, increment: () => { count++; yield text(`Count: ${count}`); } }; } ``` **Parent Component** ```typescript import { watch, child, click } from 'watch-selector'; watch('.button-container', function*() { // `childApis` is a reactive Map: Map<HTMLButtonElement, { getCount, reset, increment }> const childApis = child('button.counter', counterButton); // Parent can interact with children's APIs yield click('.reset-all-btn', () => { console.log('Resetting all child buttons...'); for (const api of childApis.values()) { api.reset(); } }); yield click('.sum-btn', () => { const total = Array.from(childApis.values()).reduce((sum, api) => sum + api.getCount(), 0); console.log(`Total count across all buttons: ${total}`); }); }); ``` ### Parent-to-Child: Accessing the Parent with `getParentContext` A child can access its parent's context and API, creating a top-down communication channel. **Parent Component** ```typescript watch('form#main-form', function*() { const isValid = createState('valid', false); // Form validation logic... // The parent's API return { submitForm: () => { if (isValid.get()) { self().submit(); } }, isValid: () => isValid.get() }; }); ``` **Child Component (inside the form)** ```typescript import { getParentContext, on, self } from 'watch-selector'; watch('input.submit-on-enter', function*() { // Get the parent form's context and API with full type safety const parentForm = getParentContext<HTMLFormElement, { submitForm: () => void; isValid: () => boolean }>(); yield on('keydown', (e) => { if (e.key === 'Enter' && parentForm) { e.preventDefault(); if (parentForm.api.isValid()) { parentForm.api.submitForm(); // Call the parent's API method } } }); }); ``` ### Real-World Example: Interactive Counter Dashboard ```typescript // Child counter component function* counterWidget() { let count = 0; const startTime = Date.now(); yield addClass('counter-widget'); yield text(`Count: ${count}`); yield click(() => { count++; yield text(`Count: ${count}`); yield addClass('updated'); setTimeout(() => yield removeClass('updated'), 200); }); // Public API for parent interaction return { getCount: () => count, getRate: () => count / ((Date.now() - startTime) / 1000), reset: () => { count = 0; yield text(`Count: ${count}`); }, setCount: (newCount: number) => { count = newCount; yield text(`Count: ${count}`); } }; } // Parent dashboard component function* counterDashboard() { const counters = child('.counter', counterWidget); // Dashboard controls yield click('.reset-all', () => { counters.forEach(api => api.reset()); }); yield click('.show-stats', () => { const stats = Array.from(counters.values()).map(api => ({ count: api.getCount(), rate: api.getRate() })); console.log('Dashboard stats:', stats); }); yield click('.distribute-evenly', () => { const total = Array.from(counters.values()).reduce((sum, api) => sum + api.getCount(), 0); const perCounter = Math.floor(total / counters.size); counters.forEach(api => api.setCount(perCounter)); }); // Parent API return { getTotalCount: () => Array.from(counters.values()).reduce((sum, api) => sum + api.getCount(), 0), getCounterCount: () => counters.size, resetAll: () => counters.forEach(api => api.reset()) }; } // Usage watch('.dashboard', counterDashboard); ``` <br> ### Building Higher-Level Abstractions Watch's primitive functions are designed to be composable building blocks for more sophisticated abstractions. You can integrate templating engines, routing libraries, state management solutions, and domain-specific tools while maintaining Watch's ergonomic patterns. ### Writing Custom Abstractions The key to building great abstractions with Watch is following the established patterns: #### 1. Dual API Pattern Make your functions work both directly and in generators: ```typescript // Custom templating integration export function template(templateOrElement: string | HTMLElement, data?: any): any { // Direct usage if (arguments.length === 2 && (typeof templateOrElement === 'string' || templateOrElement instanceof HTMLElement)) { const element = resolveElement(templateOrElement); if (element) { element.innerHTML = renderTemplate(templateOrElement as string, data); } return; } // Generator usage if (arguments.length === 1) { const templateStr = templateOrElement as string; return ((element: HTMLElement) => { element.innerHTML = renderTemplate(templateStr, data || {}); }) as ElementFn<HTMLElement>; } // Selector + data usage const [templateStr, templateData] = arguments; return ((element: HTMLElement) => { element.innerHTML = renderTemplate(templateStr, templateData); }) as ElementFn<HTMLElement>; } // Usage examples const element = document.querySelector('.content'); template(element, '<h1>{{title}}</h1>', { title: 'Hello' }); // Or in generators watch('.dynamic-content', function* () { yield template('<div>{{message}}</div>', { message: 'Dynamic!' }); }); ``` #### 2. Context-Aware Functions Create functions that understand the current element context: ```typescript // Custom router integration export function route(pattern: string, handler: () => void): ElementFn<HTMLElement> { return (element: HTMLElement) => { const currentPath = window.location.pathname; const matches = matchRoute(pattern, currentPath); if (matches) { // Store route params in element context if (!element.dataset.routeParams) { element.dataset.routeParams = JSON.stringify(matches.params); } handler(); } }; } // Route parameters helper export function routeParams<T = Record<string, string>>(): T { const element = self(); const params = element.dataset.routeParams; return params ? JSON.parse(params) : {}; } // Usage watch('[data-route]', function* () { yield route('/users/:id', () => { const { id } = routeParams<{ id: string }>(); yield template('<div>User ID: {{id}}</div>', { id }); }); }); ``` #### 3. State Integration Build abstractions that work with Watch's state system: ```typescript // Custom form validation abstraction export function validateForm(schema: ValidationSchema): ElementFn<HTMLFormElement> { return (form: HTMLFormElement) => { const errors = createState('validation-errors', {}); const isValid = createComputed(() => Object.keys(errors.get()).length === 0, ['validation-errors']); // Validate on input changes const inputs = form.querySelectorAll('input, select, textarea'); inputs.forEach(input => { input.addEventListener('blur', () => { const fieldErrors = validateField(input.name, input.value, schema); errors.update(current => ({ ...current, [input.name]: fieldErrors })); }); }); // Expose validation state form.dataset.valid = isValid().toString(); }; } // Usage watch('form.needs-validation', function* () { yield validateForm({ email: { required: true, email: true }, password: { required: true, minLength: 8 } }); yield submit((e) => { const isValid = getState('validation-errors'); if (Object.keys(isValid).length > 0) { e.preventDefault(); } }); }); ``` ### Templating Engine Integration Here's how to integrate popular templating engines: #### Handlebars Integration ```typescript import Handlebars from 'handlebars'; // Create a templating abstraction export function handlebars(templateSource: string, data?: any): ElementFn<HTMLElement>; export function handlebars(element: HTMLElement, templateSource: string, data: any): void; export function handlebars(...args: any[]): any { if (args.length === 3) { // Direct usage: handlebars(element, template, data) const [element, templateSource, data] = args; const template = Handlebars.compile(templateSource); element.innerHTML = template(data); return; } if (args.length === 2) { // Generator usage: yield handlebars(template, data) const [templateSource, data] = args; return (element: HTMLElement) => { const template = Handlebars.compile(templateSource); element.innerHTML = template(data); }; } // Template only - data from state const [templateSource] = args; return (element: HTMLElement) => { const template = Handlebars.compile(templateSource); const data = getAllState(); // Get all state as template context element.innerHTML = template(data); }; } // Helper for reactive templates export function reactiveTemplate(templateSource: string, dependencies: string[]): ElementFn<HTMLElement> { return (element: HTMLElement) => { const template = Handlebars.compile(templateSource); const render = () => { const data = getAllState(); element.innerHTML = template(data); }; // Re-render when dependencies change dependencies.forEach(dep => { watchState(dep, render); }); // Initial render render(); }; } // Usage watch('.user-profile', function* () { const user = createState('user', { name: 'John', email: 'john@example.com' }); // Template updates automatically when user state changes yield reactiveTemplate(` <h2>{{user.name}}</h2> <p>{{user.email}}</p> `, ['user']); yield click('.edit-btn', () => { user.update(u => ({ ...u, name: 'Jane' })); }); }); ``` #### Lit-html Integration ```typescript import { html, render } from 'lit-html'; export function litTemplate(template: TemplateResult): ElementFn<HTMLElement>; export function litTemplate(element: HTMLElement, template: TemplateResult): void; export function litTemplate(...args: any[]): any { if (args.length === 2) { const [element, template] = args; render(template, element); return; } const [template] = args; return (element: HTMLElement) => { render(template, element); }; } // Usage with reactive updates watch('.todo-list', function* () { const todos = createState('todos', [ { id: 1, text: 'Learn Watch', done: false }, { id: 2, text: 'Build something awesome', done: false } ]); // Template function that uses current state const todoTemplate = () => html` <ul> ${todos.get().map(todo => html` <li class="${todo.done ? 'done' : ''}"> <input type="checkbox" .checked=${todo.done} @change=${() => toggleTodo(todo.id)}> ${todo.text} </li> `)} </ul> `; // Re-render when todos change watchState('todos', () => { yield litTemplate(todoTemplate()); }); // Initial render yield litTemplate(todoTemplate()); }); ``` ### Router Integration Create routing abstractions that work seamlessly with Watch: ```typescript // Simple router abstraction class WatchRouter { private routes = new Map<string, RouteHandler>(); route(pattern: string, handler: RouteHandler): ElementFn<HTMLElement> { this.routes.set(pattern, handler); return (element: HTMLElement) => { const checkRoute = () => { const path = window.location.pathname; const match = this.matchRoute(pattern, path); if (match) { // Store route context element.dataset.routeParams = JSON.stringify(match.params); element.dataset.routeQuery = JSON.stringify(match.query); // Execute handler with route context handler(match); } }; // Check on load and route changes checkRoute(); window.addEventListener('popstate', checkRoute); // Cleanup cleanup(() => { window.removeEventListener('popstate', checkRoute); }); }; } private matchRoute(pattern: string, path: string) { // Route matching logic... return { params: {}, query: {} }; } } const router = new WatchRouter(); // Route-aware helper functions export function routeParams<T = Record<string, any>>(): T { const element = self(); const params = element.dataset.routeParams; return params ? JSON.parse(params) : {}; } export function routeQuery<T = Record<string, any>>(): T { const element = self(); const query = element.dataset.routeQuery; return query ? JSON.parse(query) : {}; } export const route = router.route.bind(router); // Usage watch('[data-route="/users/:id"]', function* () { yield route('/users/:id', ({ params }) => { const userId = params.id; // Load user data const user = createState('user', null); fetch(`/api/users/${userId}`) .then(r => r.json()) .then(userData => user.set(userData)); // Reactive template watchState('user', (userData) => { if (userData) { yield handlebars(` <div class="user-profile"> <h1>{{name}}</h1> <p>{{email}}</p> </div> `, userData); } }); }); }); ``` ### State Management Integration Integrate with external state management libraries: ```typescript // Redux integration import { Store } from 'redux'; export function connectRedux<T>( store: Store<T>, selector: (state: T) => any, mapDispatchToProps?: any ): ElementFn<HTMLElement> { return (element: HTMLElement) => { let currentValue = selector(store.getState()); const handleChange = () => { const newValue = selector(store.getState()); if (newValue !== currentValue) { currentValue = newValue; // Update element state setState('redux-state', newValue); } }; const unsubscribe = store.subscribe(handleChange); // Initial state setState('redux-state', currentValue); // Provide dispatch function if (mapDispatchToProps) { const dispatchers = mapDispatchToProps(store.dispatch); setState('redux-dispatch', dispatchers); } cleanup(() => unsubscribe()); }; } // Usage watch('.connected-component', function* () { yield connectRedux( store, state => state.user, dispatch => ({ updateUser: (user) => dispatch({ type: 'UPDATE_USER', user }) }) ); // Use Redux state in templates watchState('redux-state', (user) => { yield template('<div>Hello {{name}}</div>', user); }); yield click('.update-btn', () => { const { updateUser } = getState('redux-dispatch'); updateUser({ name: 'New Name' }); }); }); ``` ### Domain-Specific Abstractions Create specialized tools for specific use cases: ```typescript // E-commerce specific abstractions export function cart(): ElementFn<HTMLElement> { return (element: HTMLElement) => { const items = createState('cart-items', []); const total = createComputed( () => items.get().reduce((sum, item) => sum + item.price * item.quantity, 0), ['cart-items'] ); // Expose cart API globally window.cart = { add: (item) => items.update(current => [...current, item]), remove: (id) => items.update(current => current.filter(i => i.id !== id)), getTotal: () => total() }; }; } export function addToCart(productId: string, price: number): ElementFn<HTMLButtonElement> { return (button: HTMLButtonElement) => { button.addEventListener('click', () => { window.cart.add({ id: productId, price, quantity: 1 }); // Visual feedback addClass(button, 'added'); setTimeout(() => removeClass(button, 'added'), 1000); }); }; } // Data fetching abstraction export function fetchData<T>( url: string, options?: RequestInit ): ElementFn<HTMLElement> { return (element: HTMLElement) => { const data = createState<T | null>('fetch-data', null); const loading = createState('fetch-loading', true); const error = createState<Error | null>('fetch-error', null); fetch(url, options) .then(response => response.json()) .then(result => { data.set(result); loading.set(false); }) .catch(err => { error.set(err); loading.set(false); }); }; } // Usage of domain abstractions watch('.product-page', function* () { // Initialize cart yield cart(); // Fetch product data yield fetchData('/api/products/123'); // Reactive content based on loading state watchState('fetch-loading', (isLoading) => { if (isLoading) { yield template('<div class="loading">Loading...</div>'); } }); // Reactive content based on data watchState('fetch-data', (product) => { if (product) { yield template(` <div class="product"> <h1>{{name}}</h1> <p>{{description}}</p> <span class="price">${{price}}</span> <button class="add-to-cart">Add to Cart</button> </div> `, product); } }); // Add to cart functionality yield click('.add-to-cart', () => { const product = getState('fetch-data'); yield addToCart(product.id, product.price); }); }); ``` ### Creating Reusable Component Libraries Build component libraries that follow Watch's patterns: ```typescript // UI Component library built on Watch export const UI = { // Modal component modal(options: { title?: string, closable?: boolean } = {}): ElementFn<HTMLElement> { return (element: HTMLElement) => { const isOpen = createState('modal-open', false); // Setup modal structure yield template(` <div class="modal-backdrop" style="display: none;"> <div class="modal-content"> ${options.title ? `<h2>${options.title}</h2>` : ''} <div class="modal-body"></div> ${options.closable ? '<button class="modal-close">ร—</button>' : ''} </div> </div> `); // Show/hide logic watchState('modal-open', (open) => { const backdrop = el('.modal-backdrop'); if (backdrop) { backdrop.style.display = open ? 'flex' : 'none'; } }); if (options.closable) { yield click('.modal-close', () => { isOpen.set(false); }); } // Expose modal API return { open: () => isOpen.set(true), close: () => isOpen.set(false), toggle: () => isOpen.update(current => !current) }; }; }, // Tabs component tabs(): ElementFn<HTMLElement> { return (element: HTMLElement) => { const activeTab = createState('active-tab', 0); // Setup tab navigation const tabButtons = all('.tab-button'); const tabPanels = all('.tab-panel'); tabButtons.forEach((button, index) => { button.addEventListener('click', () => { activeTab.set(index); }); }); // Show/hide panels based on active tab watchState('active-tab', (active) => { tabPanels.forEach((panel, index) => { panel.style.display = index === active ? 'block' : 'none'; }); tabButtons.forEach((button, index) => { button.classList.toggle('active', index === active); }); }); }; } }; // Usage watch('.my-modal', function* () { const modalApi = yield UI.modal({ title: 'Settings', closable: true }); yield click('.open-modal', () => { modalApi.open(); }); }); watch('.tab-container', function* () { yield UI.tabs(); }); ``` ### Best Practices for Abstractions 1. **Follow the Dual API Pattern**: Make functions work both directly and in generators 2. **Use Element-Scoped State**: Keep component state isolated per element instance 3. **Provide Type Safety**: Use TypeScript generics and proper typing 4. **Compose with Existing Functions**: Build on Watch's primitive functions 5. **Handle Cleanup**: Always clean up external resources 6. **Maintain Context**: Use `self()`, `el()`, and context functions appropriately 7. **Return APIs**: Let components expose public interfaces through return values This approach lets you build powerful, domain-specific libraries while maintaining Watch's ergonomic patterns and type safety guarantees. ### Generator Abstractions: When to Wrap the Generator Itself Sometimes you need to wrap or transform the generator pattern itself, not just individual functions. This is useful for cross-cutting concerns, meta-functionality, and standardizing behaviors across components. #### When to Use Generator Abstractions vs Function Abstractions **Use Function Abstractions When:** - Adding specific functionality (templating, validation, etc.) - Building domain-specific operations - Creating reusable behaviors - Extending the dual API pattern **Use Generator Abstractions When:** - Adding cross-cutting concerns (logging, performance, error handling) - Standardizing component patterns across teams - Injecting behavior into ALL components - Creating meta-frameworks or higher-level patterns - Managing component lifecycles uniformly #### Performance Monitoring Generator ```typescript // Wraps any generator to add performance monitoring export function withPerformanceMonitoring<T extends HTMLElement>( name: string, generator: () => Generator<ElementFn<T>, any, unknown> ): () => Generator<ElementFn<T>, any, unknown> { return function* () { const startTime = performance.now(); console.log(`๐Ÿš€ Component "${name}" starting...`); try { // Execute the original generator const originalGen = generator(); let result = originalGen.next(); while (!result.done) { // Time each yielded operation const opStart = performance.now(); yield result.value; const opEnd = performance.now(); // Log slow operations if (opEnd - opStart > 10) { console.warn(`โš ๏ธ Slow operation in "${name}": ${opEnd - opStart}ms`); } result = originalGen.next(); } const endTime = performance.now(); console.log(`โœ… Component "${name}" initialized in ${endTime - startTime}ms`); return result.value; // Return the original generator's return value } catch (error) { const endTime = performance.now(); console.error(`โŒ Component "${name}" failed after ${endTime - startTime}ms:`, error); throw error; } }; } // Usage const monitoredButton = withPerformanceMonitoring('InteractiveButton', function* () { yield addClass('interactive'); yield click(() => console.log('Clicked!')); return { disable: () => yield addClass('disabled') }; }); watch('button', monitoredButton); ``` #### Error Boundary Generator ```typescript // Wraps generators with error handling and fallback UI export function withErrorBoundary<T extends HTMLElement>( generator: () => Generator<ElementFn<T>, any, unknown>, fallbackContent?: string, onError?: (error: Error, element: T) => void ): () => Generator<ElementFn<T>, any, unknown> { return function* () { try { yield* generator(); } catch (error) { console.error('Component error:', error); // Show fallback UI if (fallbackContent) { yield text(fallbackContent); yield addClass('error-state'); } // Call custom error handler if (onError) { const element = self() as T; onError(error as Error, element); } // Return safe fallback API return { hasError: true, retry: () => { // Could implement retry logic here window.location.reload(); } }; } }; } // Usage const safeComponent = withErrorBoundary( function* () { // This might throw an error const data = JSON.parse(self().dataset.config || ''); yield template('<div>{{message}}</div>', data); throw new Error('Something went wrong!'); // Simulated error }, 'Something went wrong. Please try again.', (error, element) => { // Send error to logging service console.error('Logging error for element:', element.id, error); } ); watch('.risky-component', safeComponent); ``` #### Feature Flag Generator ```typescript // Wraps generators with feature flag checks export function withFeatureFlag<T extends HTMLElement>( flagName: string, generator: () => Generator<ElementFn<T>, any, unknown>, fallbackGenerator?: () => Generator<ElementFn<T>, any, unknown> ): () => Generator<ElementFn<T>, any, unknown> { return function* () { const isEnabled = await checkFeatureFlag(flagName); if (isEnabled) { console.log(`๐ŸŽฏ Feature "${flagName}" enabled`); yield* generator(); } else if (fallbackGenerator) { console.log(`๐Ÿšซ Feature "${flagName}" disabled, using fallback`); yield* fallbackGenerator(); } else { console.log(`๐Ÿšซ Feature "${flagName}" disabled, no fallback`); // Component does nothing } }; } // Usage const newButtonBehavior = withFeatureFlag( 'enhanced-buttons', function* () { // New enhanced behavior yield addClass('enhanced'); yield style({ background: 'linear-gradient(45deg, #007bff, #0056b3)', transition: 'all 0.3s ease' }); yield click(() => { yield addClass('clicked'); setTimeout(() => yield removeClass('clicked'), 300); }); }, function* () { // Fallback to old behavior yield addClass('basic'); yield click(() => console.log('Basic click')); } ); watch('button.enhanced', newButtonBehavior); ``` #### Lifecycle Management Generator ```typescript // Adds standardized lifecycle hooks to any generator export function withLifecycle<T extends HTMLElement, R = any>( generator: () => Generator<ElementFn<T>, R, unknown>, options: { onMount?: (element: T) => void; onUnmount?: (element: T) => void; onUpdate?: (element: T) => void; enableDebug?: boolean; } = {} ): () => Generator<ElementFn<T>, R, unknown> { return function* () { const element = self() as T; const componentName = element.className || element.tagName.toLowerCase(); // Mount lifecycle if (options.onMount) { options.onMount(element); } if (options.enableDebug) { console.log(`๐Ÿ”ง Mounting component: ${componentName}`); } // Setup unmount cleanup if (options.onUnmount) { cleanup(() => { if (options.enableDebug) { console.log(`๐Ÿ—‘๏ธ Unmounting component: ${componentName}`); } options.onUnmount!(element); }); } // Track updates if enabled if (options.onUpdate) { const observer = new MutationObserver(() => { options.onUpdate!(element); }); observer.observe(element, { attributes: true, childList: true, subtree: true }); cleanup(() => observer.disconnect()); } // Execute the wrapped generator const result = yield* generator(); if (options.enableDebug) { console.log(`โœ… Component initialized: ${componentName}`); } return result; }; } // Usage const lifecycleComponent = withLifecycle( function* () { const clickCount = createState('clicks', 0); yield click(() => { clickCount.update(c => c + 1); yield text(`Clicked ${clickCount.get()} times`); }); return { getClicks: () => clickCount.get() }; }, { onMount: (el) => console.log(`Component mounted on:`, el), onUnmount: (el) => console.log(`Component unmounted from:`, el), onUpdate: (el) => console.log(`Component updated:`, el), enableDebug: true } ); watch('.lifecycle-component', lifecycleComponent); ``` #### A/B Testing Generator ```typescript // Enables A/B testing at the component level export function withABTest<T extends HTMLElement>( testName: string, variants: Record<string, () => Generator<ElementFn<T>, any, unknown>>, options: { userIdGetter?: () => string; onVariantShown?: (variant: string, userId: string) => void; } = {} ): () => Generator<ElementFn<T>, any, unknown> { return function* () { const userId = options.userIdGetter?.() || 'anonymous'; const variant = selectVariant(testName, userId, Object.keys(variants)); // Track which variant was shown if (options.onVariantShown) { options.onVariantShown(variant, userId); } // Store variant info on element for debugging const element = self() as T; element.dataset.abTest = testName; element.dataset.abVariant = variant; console.log(`๐Ÿงช A/B Test "${testName}": showing variant "${variant}" to user ${userId}`); // Execute the selected variant const selectedGenerator = variants[variant]; if (selectedGenerator) { yield* selectedGenerator(); } else { console.warn(`โš ๏ธ A/B Test "${testName}": variant "${variant}" not found`); } }; } // Usage const abTestButton = withABTest( 'button-style-test', { control: function* () { yield addClass('btn-primary'); yield text('Click Me'); yield click(() => console.log('Control clicked')); }, variant_a: function* () { yield addClass('btn-success'); yield text('Take Action!'); yield style({ fontSize: '18px', fontWeight: 'bold' }); yield click(() => console.log('Variant A clicked')); }, variant_b: function* () { yield addClass('btn-warning'); yield text('Get Started'); yield style({ borderRadius: '25px' }); yield click(() => console.log('Variant B clicked')); } }, { userIdGetter: () => getCurrentUserId(), onVariantShown: (variant, userId) => { analytics.track('ab_test_variant_shown', { test: 'button-style-test', variant, userId }); } } ); watch('.ab-test-button', abTestButton); ``` #### Permission-Based Generator ```typescript // Wraps generators with permission checks export function withPermissions<T extends HTMLElement>( requiredPermissions: string[], generator: () => Generator<ElementFn<T>, any, unknown>, unauthorizedGenerator?: () => Generator<ElementFn<T>, any, unknown> ): () => Generator<ElementFn<T>, any, unknown> { return function* () { const hasPermission = await checkPermissions(requiredPermissions); if (hasPermission) { yield* generator(); } else { console.log(`๐Ÿ”’ Access denied. Required permissions: ${requiredPermissions.join(', ')}`); if (unauthorizedGenerator) { yield* unauthorizedGenerator(); } else { // Default unauthorized behavior yield addClass('unauthorized'); yield text('Access Denied'); yield click(() => { alert('You do not have permission to use this feature.'); }); } } }; } // Usage const adminButton = withPermissions( ['admin', 'user_management'], function* () { yield text('Delete User'); yield addClass('btn-danger'); yield click(() => { if (confirm('Are you sure?')) { deleteUser(); } }); }, function* () { yield text('Contact Admin'); yield addClass('btn-secondary'); yield click(() => { window.location.href = 'mailto:admin@company.com'; }); } ); watch('.admin-action', adminButton); ``` #### Higher-Order Generator Composition ```typescript // Combine multiple generator wrappers export function compose<T extends HTMLElement>( ...wrappers: Array<(gen: () => Generator<ElementFn<T>, any, unknown>) => () => Generator<ElementFn<T>, any, unknown>> ) { return (generator: () => Generator<ElementFn<T>, any, unknown>) => { return wrappers.reduceRight((acc, wrapper) => wrapper(acc), generator); }; } // Usage - apply multiple concerns to a component const enhancedComponent = compose( // Applied in reverse order (inside-out) gen => withPerformanceMonitoring('MyComponent', gen), gen => withErrorBoundary(gen, 'Component failed to load'), gen => withLifecycle(gen, { enableDebug: true }), gen => withFeatureFlag('new-ui', gen, () => function* () { yield text('Feature disabled'); }) )(function* () { // The actual component logic const count = createState('count', 0); yield click(() => { count.update(c => c + 1); yield text(`Count: ${count.get()}`); }); return { getCount: () => count.get() }; }); watch('.enhanced-component', enhancedComponent); ``` #### Component Factory Generator ```typescript // Creates standardized component patterns export function createComponent<T extends HTMLElement>( name: string, config: { template?: string; styles?: Record<string, string>; state?: Record<string, any>; methods?: Record<string, (...args: any[]) => any>; lifecycle?: { onMount?: (element: T) => void; onUnmount?: (element: T) => void; }; } ): () => Generator<ElementFn<T>, any, unknown> { return function* () { const element = self() as T; // Apply template if (config.template) { yield html(config.template); } // Apply styles if (config.styles) { yield style(config.styles); } // Initialize state const componentState: Record<string, any> = {}; if (config.state) { Object.entries(config.state).forEach(([key, initialValue]) => { componentState[key] = createState(key, initialValue); }); } // Lifecycle hooks if (config.lifecycle?.onMount) { config.lifecycle.onMount(element); } if (config.lifecycle?.onUnmount) { cleanup(() => config.lifecycle!.onUnmount!(element)); } // Create public API const api: Record<string, any> = {}; if (config.methods) { Object.entries(config.methods).forEach(([methodName, method]) => { api[methodName] = (...args: any[]) => { return method.call({ element, state: componentState }, ...args);