watch-selector
Version:
Runs a function when a selector is added to dom
1,665 lines (1,567 loc) • 128 kB
text/typescript
/**
* DOM API Implementation with Sync Generators and Branded Types
*
* This module implements DOM manipulation functions that support multiple API patterns:
* 1. Direct element manipulation: text(element, 'content')
* 2. CSS selector manipulation: text('#id', 'content')
* 3. Generator with yield*: yield* text('content')
*
* All functions use sync generators and the yield* pattern for better type safety.
*/
import type { Workflow, WatchContext, Operation } from "../types";
import { getCurrentContext } from "../core/context";
import {
isCSSSelector,
type CSSSelector,
type ClassName,
} from "../utils/selector-types";
// ============================================================================
// Type Definitions
// ============================================================================
// More specific style value types with literal types for common CSS values
type CSSLengthUnit =
| "px"
| "em"
| "rem"
| "%"
| "vh"
| "vw"
| "vmin"
| "vmax"
| "ch"
| "ex"
| "cm"
| "mm"
| "in"
| "pt"
| "pc";
type CSSLength =
| `${number}${CSSLengthUnit}`
| number
| "0"
| "auto"
| "inherit"
| "initial"
| "unset";
type CSSColor =
| `#${string}`
| `rgb(${string})`
| `rgba(${string})`
| `hsl(${string})`
| `hsla(${string})`
| "transparent"
| "currentColor"
| "inherit";
type StyleValue = string | number | null | undefined;
type StyleObject<
K extends keyof CSSStyleDeclaration = keyof CSSStyleDeclaration,
> = {
[P in K]?: CSSStyleDeclaration[P] | StyleValue;
};
type AttributeObject = Record<
string,
string | number | boolean | null | undefined
>;
type DataObject<T = any> = Record<string, T>;
// Type-safe style properties with autocomplete
type CSSStyleProperties = Partial<CSSStyleDeclaration>;
// Better display value types
type DisplayValue =
| "none"
| "block"
| "inline"
| "inline-block"
| "flex"
| "inline-flex"
| "grid"
| "inline-grid"
| "table"
| "table-row"
| "table-cell"
| "contents"
| "list-item"
| "run-in";
// Position types
type PositionValue = "static" | "relative" | "absolute" | "fixed" | "sticky";
// More specific element types for better inference
type FormElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
type FocusableElement = HTMLElement & { focus(): void; blur(): void };
type ValueElement =
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| HTMLOutputElement;
// Generic constraint for element queries with better type narrowing
type ElementConstraint = Element | HTMLElement | SVGElement;
type HTMLElementConstraint =
| HTMLElement
| HTMLDivElement
| HTMLSpanElement
| HTMLButtonElement
| HTMLInputElement;
type QueryConstraint<T = Element> = T extends Element ? T : Element;
// Strict element type mapping for better inference
type StrictElementMap<K extends keyof HTMLElementTagNameMap> =
HTMLElementTagNameMap[K];
type StrictSVGElementMap<K extends keyof SVGElementTagNameMap> =
SVGElementTagNameMap[K];
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Type guard: Check if a value is an HTML element with generic constraint
*/
function isHTMLElement<T extends HTMLElement = HTMLElement>(
value: unknown,
tagName?: keyof HTMLElementTagNameMap,
): value is T {
if (!(value instanceof HTMLElement)) return false;
if (
tagName &&
(value as HTMLElement).tagName.toLowerCase() !== tagName.toLowerCase()
) {
return false;
}
return true;
}
/**
* Type guard for specific HTML element types
*/
// Removed unused isSpecificHTMLElement function
/**
* Type guard: Check if a value is an Element with generic constraint
*/
function isElement<T extends Element = Element>(
value: unknown,
tagName?: keyof HTMLElementTagNameMap | keyof SVGElementTagNameMap,
): value is T {
if (!(value instanceof Element)) return false;
if (tagName && value.tagName.toLowerCase() !== tagName.toLowerCase()) {
return false;
}
return true;
}
// Removed unused isNode and isDocument functions
/**
* Type guard: Check if element can receive focus
*/
function isFocusable(element: unknown): element is FocusableElement {
return (
element instanceof HTMLElement &&
typeof (element as any).focus === "function" &&
typeof (element as any).blur === "function"
);
}
/**
* Type guard: Check if element has a value property
*/
function hasValue(element: unknown): element is ValueElement {
return (
element instanceof HTMLElement &&
"value" in element &&
(element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement ||
element instanceof HTMLOutputElement)
);
}
// Removed unused isFormControl function
/**
* Check if we're in a generator context
*/
function isInGeneratorContext(): boolean {
return getCurrentContext() !== null;
}
/**
* Resolve elements from a selector string with generic type support
*/
function resolveElements<T extends Element = HTMLElement>(
selector: string,
root: Element | Document = document,
): T[] {
const elements: T[] = [];
root.querySelectorAll(selector).forEach((el) => {
elements.push(el as T);
});
return elements;
}
// Removed unused resolveElementsStrict function
/**
* Resolve single element with generic type support
*/
function resolveElement<T extends Element = HTMLElement>(
selector: string,
root: Element | Document = document,
): T | null {
const element = root.querySelector(selector);
return element as T | null;
}
// Removed unused resolveElementSafe function
// Removed unused queryElement and queryElements functions
/**
* Enhanced selector detection with better heuristics
*/
function looksLikeSelector(value: unknown): boolean {
if (typeof value !== "string") return false;
// Use our improved heuristic from selector-types
return isCSSSelector(value);
}
// ============================================================================
// Text Function
// ============================================================================
/**
* Sets or gets text content of elements with full type safety and multiple usage patterns.
*
* This function provides a unified API for text manipulation that works in three distinct patterns:
* 1. Direct element manipulation - Pass an element directly
* 2. CSS selector targeting - Use a selector string to find and modify elements
* 3. Generator context - Use within watch() generators with yield
*
* @example Direct element manipulation
* ```typescript
* const button = document.querySelector('button');
* // Set text
* text(button, 'Click me');
* // Get text
* const content = text(button); // returns string
* ```
*
* @example CSS selector pattern
* ```typescript
* // Set text for all matching elements
* text('.button', 'Click me');
* // Get text from first matching element
* const content = text('.button'); // returns string | null
* ```
*
* @example Generator context with yield
* ```typescript
* import { watch, text } from 'watch-selector';
*
* watch('.dynamic-content', function* () {
* // Set text
* yield text('Loading...');
*
* // Get current text
* const current = yield text();
* console.log('Current text:', current);
*
* // Update with dynamic content
* yield text(`Loaded at ${new Date().toLocaleTimeString()}`);
* });
* ```
*
* @example With template literals and variables
* ```typescript
* text('button', 'Click me');
* ```
*
* @example Generator pattern
* ```typescript
* watch('button', function* () {
* yield* text('Click me');
* const content = yield* text();
* });
* ```
*/
export function text(element: HTMLElement, content: string | number): void;
export function text(selector: string, content: string | number): void;
export function text(selector: CSSSelector, content: string | number): void;
export function text<T extends HTMLElement = HTMLElement>(
element: T,
content: string | number,
): void;
export function text<T extends HTMLElement = HTMLElement>(element: T): string;
export function text(
selector: string | CSSSelector,
content: string | number,
): void;
export function text(selector: string | CSSSelector): string | null;
export function text(content: string | number): Workflow<void>;
export function text(): Workflow<string>;
export function text<T extends HTMLElement = HTMLElement>(...args: any[]): any {
// Direct element manipulation setter
if (args.length === 2 && isHTMLElement(args[0])) {
const [element, content] = args as [T, string | number];
element.textContent = String(content);
return;
}
// Direct element manipulation getter
if (args.length === 1 && isHTMLElement(args[0])) {
const [element] = args as [T];
return element.textContent || "";
}
// CSS selector manipulation setter
if (args.length === 2 && typeof args[0] === "string") {
const [selector, content] = args as [string, string | number];
const elements = resolveElements<HTMLElement>(String(selector));
elements.forEach((el) => {
el.textContent = String(content);
});
return;
}
// CSS selector manipulation getter
if (
args.length === 1 &&
typeof args[0] === "string" &&
!isInGeneratorContext()
) {
const [selector] = args as [string];
const element = resolveElement<HTMLElement>(String(selector));
return element ? element.textContent || "" : null;
}
// Generator setter pattern - returns sync Workflow
if (args.length === 1) {
const [content] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
context.element.textContent = String(content);
}) as Operation<void>;
})();
}
// Generator getter pattern - returns sync Workflow
if (args.length === 0) {
return (function* (): Generator<Operation<string>, string, any> {
const result = yield ((context: WatchContext) => {
return context.element.textContent || "";
}) as Operation<string>;
return result;
})();
}
throw new Error(
`Invalid arguments for text(): ${args.length} arguments provided`,
);
}
// ============================================================================
// HTML Function
// ============================================================================
/**
* Sets or gets HTML content of elements with full type safety.
*
* ⚠️ WARNING: Setting HTML content can expose your application to XSS attacks.
* Always sanitize user input before using it as HTML content.
*
* @example Direct element manipulation
* ```typescript
* const container = document.querySelector('.content');
* // Set HTML (be careful with user input!)
* html(container, '<strong>Bold text</strong>');
* // Get HTML
* const markup = html(container); // returns string
* ```
*
* @example CSS selector pattern
* ```typescript
* // Set HTML for all matching elements
* html('.card-body', '<p>Card content</p>');
* // Get HTML from first matching element
* const markup = html('.card-body'); // returns string | null
* ```
*
* @example Generator context
* ```typescript
* import { watch, html } from 'watch-selector';
*
* watch('.markdown-output', function* () {
* yield html('<p>Rendering...</p>');
*
* // Fetch and render markdown
* const response = await fetch('/api/content');
* const rendered = await response.text();
*
* // ⚠️ Only use with trusted content!
* yield html(rendered);
* });
* ```
*
* @param element - The element to manipulate (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param content - HTML content to set (optional, if not provided, gets content)
* @returns void when setting, string when getting, or Workflow in generator context
*/
export function html<T extends HTMLElement = HTMLElement>(
element: T,
content: string,
): void;
export function html<T extends HTMLElement = HTMLElement>(element: T): string;
export function html(selector: string | CSSSelector, content: string): void;
export function html(selector: string | CSSSelector): string | null;
export function html(content: string): Workflow<void>;
export function html(): Workflow<string>;
export function html<T extends HTMLElement = HTMLElement>(...args: any[]): any {
// Direct element manipulation setter
if (args.length === 2 && isHTMLElement(args[0])) {
const [element, content] = args as [T, string];
element.innerHTML = String(content);
return;
}
// Direct element manipulation getter
if (args.length === 1 && isHTMLElement(args[0])) {
const [element] = args as [T];
return element.innerHTML;
}
// CSS selector manipulation setter
if (args.length === 2 && typeof args[0] === "string") {
const [selector, content] = args as [string, string];
const elements = resolveElements<HTMLElement>(String(selector));
elements.forEach((el) => {
el.innerHTML = String(content);
});
return;
}
// CSS selector manipulation getter
if (
args.length === 1 &&
typeof args[0] === "string" &&
!isInGeneratorContext()
) {
const [selector] = args as [string];
const element = resolveElement<HTMLElement>(String(selector));
return element ? element.innerHTML : null;
}
// Generator setter pattern
if (args.length === 1) {
const [content] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
context.element.innerHTML = String(content);
}) as Operation<void>;
})();
}
// Generator getter pattern
if (args.length === 0) {
return (function* (): Generator<Operation<string>, string, any> {
const result = yield ((context: WatchContext) => {
return context.element.innerHTML;
}) as Operation<string>;
return result;
})();
}
throw new Error(
`Invalid arguments for html(): ${args.length} arguments provided`,
);
}
// ============================================================================
// Class Functions
// ============================================================================
/**
* Adds one or more CSS classes to elements with intelligent deduplication.
*
* Supports space-separated class names and automatically handles duplicates.
* Classes are only added if they don't already exist on the element.
*
* @param element - HTMLElement to add classes to (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param className - Single class or space-separated classes to add
* @returns void for direct/selector patterns, Workflow for generator pattern
*
* @example Adding classes in generators with yield*
* ```typescript
* import { watch, addClass, click } from 'watch-selector';
*
* watch('.card', function* () {
* // Add single class
* yield* addClass('interactive');
*
* // Add multiple classes at once
* yield* addClass('shadow-lg rounded bordered');
*
* yield* click(function* () {
* // Add state classes reactively
* yield* addClass('selected highlighted');
* });
* });
* ```
*
* @example Conditional class addition
* ```typescript
* watch('.notification', function* () {
* const type = yield* getState('type', 'info');
*
* // Add classes based on state
* yield* addClass('notification');
* yield* addClass(`notification-${type}`);
*
* if (type === 'error') {
* yield* addClass('urgent shake-animation');
* }
* });
* ```
*
* @example Animation and transition classes
* ```typescript
* watch('.modal', function* () {
* // Prepare for animation
* yield* addClass('modal-base');
*
* // Trigger animation after a frame
* yield* onMount(function* () {
* requestAnimationFrame(() => {
* yield* addClass('fade-in slide-up');
* });
* });
* });
* ```
*/
// Class manipulation with generics
export function addClass<T extends Element = HTMLElement>(
element: T,
className: string | ClassName,
): void;
export function addClass<T extends Element = HTMLElement>(
selector: string | CSSSelector,
className: string | ClassName,
): void;
export function addClass(className: string | ClassName): Workflow<void>;
export function addClass<T extends Element = HTMLElement>(...args: any[]): any {
// Direct element manipulation
if (args.length === 2 && isElement(args[0])) {
const [element, className] = args as [T, string | ClassName];
const classes = String(className).split(/\s+/).filter(Boolean);
element.classList.add(...classes);
return;
}
// CSS selector manipulation
if (args.length === 2 && looksLikeSelector(args[0])) {
const [selector, className] = args as [
string | CSSSelector,
string | ClassName,
];
const classes = String(className).split(/\s+/).filter(Boolean);
const elements = resolveElements<T>(String(selector));
elements.forEach((el) => {
el.classList.add(...classes);
});
return;
}
// Generator pattern
if (args.length === 1) {
const [className] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
const classes = String(className).split(/\s+/).filter(Boolean);
context.element.classList.add(...classes);
}) as Operation<void>;
})();
}
throw new Error(
`Invalid arguments for addClass(): ${args.length} arguments provided`,
);
}
/**
* Removes one or more CSS classes from elements.
*
* Supports space-separated class names and safely handles non-existent classes.
*
* @example Direct element manipulation
* ```typescript
* const modal = document.querySelector('.modal');
* // Remove single class
* removeClass(modal, 'hidden');
* // Remove multiple classes
* removeClass(modal, 'hidden fade-out disabled');
* ```
*
* @example CSS selector pattern
* ```typescript
* // Remove classes from all matching elements
* removeClass('.error-field', 'error highlighted');
* // Clear loading states
* removeClass('.loading', 'loading spinner');
* ```
*
* @example Generator context
* ```typescript
* import { watch, removeClass, addClass, delay } from 'watch-selector';
*
* watch('.notification', function* () {
* yield addClass('visible slide-in');
* yield delay(3000);
* yield removeClass('visible');
* yield addClass('slide-out');
* });
* ```
*
* @param element - The element to remove classes from (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param className - Space-separated class names to remove
* @returns void when used directly, Workflow in generator context
*/
/**
* Removes one or more CSS classes from elements.
*
* Supports space-separated class names and safely handles non-existent classes.
* No error is thrown if a class doesn't exist on the element.
*
* @param element - HTMLElement to remove classes from (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param className - Single class or space-separated classes to remove
* @returns void for direct/selector patterns, Workflow for generator pattern
*
* @example Removing classes with yield* in generators
* ```typescript
* import { watch, removeClass, addClass, click } from 'watch-selector';
*
* watch('.toggle-button', function* () {
* yield* click(function* () {
* // Remove multiple classes at once
* yield* removeClass('inactive disabled');
* yield* addClass('active enabled');
* });
* });
* ```
*
* @example State transitions with class swapping
* ```typescript
* watch('.status-indicator', function* () {
* const status = yield* getState('status', 'pending');
*
* // Clear all possible status classes
* yield* removeClass('status-pending status-loading status-success status-error');
*
* // Add the current status class
* yield* addClass(`status-${status}`);
* });
* ```
*
* @example Animation cleanup
* ```typescript
* watch('.animated-element', function* () {
* yield* click(function* () {
* // Trigger animation
* yield* addClass('animating bounce');
*
* // Clean up after animation completes
* setTimeout(() => {
* yield* removeClass('animating bounce');
* yield* addClass('animation-complete');
* }, 1000);
* });
* });
* ```
*/
export function removeClass<T extends Element = HTMLElement>(
element: T,
className: string | ClassName,
): void;
export function removeClass<T extends Element = HTMLElement>(
selector: string | CSSSelector,
className: string | ClassName,
): void;
export function removeClass(className: string | ClassName): Workflow<void>;
export function removeClass<T extends Element = HTMLElement>(
...args: any[]
): any {
// Direct element manipulation
if (args.length === 2 && isElement(args[0])) {
const [element, className] = args as [T, string | ClassName];
const classes = String(className).split(/\s+/).filter(Boolean);
element.classList.remove(...classes);
return;
}
// CSS selector manipulation
if (args.length === 2 && looksLikeSelector(args[0])) {
const [selector, className] = args as [
string | CSSSelector,
string | ClassName,
];
const classes = String(className).split(/\s+/).filter(Boolean);
const elements = resolveElements<T>(String(selector));
elements.forEach((el) => {
el.classList.remove(...classes);
});
return;
}
// Generator pattern
if (args.length === 1) {
const [className] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
const classes = String(className).split(/\s+/).filter(Boolean);
context.element.classList.remove(...classes);
}) as Operation<void>;
})();
}
throw new Error(
`Invalid arguments for removeClass(): ${args.length} arguments provided`,
);
}
/**
* Sets or gets inline style properties on elements.
*
* Supports multiple usage patterns:
* - Set single property: style(element, 'color', 'red')
* - Set multiple properties: style(element, { color: 'red', fontSize: '16px' })
* - Get property value: style(element, 'color')
*
* @example Direct element - setting styles
* ```typescript
* const box = document.querySelector('.box');
* // Set single property
* style(box, 'backgroundColor', '#ff0000');
* style(box, 'padding', '20px');
* // Set with number (adds 'px' for applicable properties)
* style(box, 'width', 200); // becomes '200px'
* // Set multiple properties at once
*al force parameter.
*
* @example Direct element manipulation
* ```typescript
* const panel = document.querySelector('.panel');
* // Toggle class
* toggleClass(panel, 'expanded');
* // Force add (true) or remove (false)
* toggleClass(panel, 'active', isActive);
* ```
*
* @example CSS selector pattern
* ```typescript
* // Toggle on all matching elements
* toggleClass('.accordion-item', 'open');
* // Force state based on condition
* toggleClass('.menu', 'visible', window.innerWidth > 768);
* ```
*
* @example Generator context
* ```typescript
* import { watch, toggleClass, click } from 'watch-selector';
*
* watch('.toggle-switch', function* () {
* yield click(function* () {
* yield toggleClass('on');
* const isOn = yield hasClass('on');
* console.log('Switch is:', isOn ? 'ON' : 'OFF');
* });
* });
* ```
*
* @param element - The element to toggle classes on (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param className - Space-separated class names to toggle
* @param force - Optional: true to add, false to remove, undefined to toggle
* @returns void when used directly, Workflow in generator context
*/
/**
* Toggles CSS classes on elements with optional force flag.
*
* Intelligently adds or removes classes based on their current presence.
* The optional force parameter allows explicit control over the operation.
*
* @param element - HTMLElement to toggle classes on (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param className - Single class or space-separated classes to toggle
* @param force - If true, adds class; if false, removes class; if undefined, toggles
* @returns void for direct/selector patterns, Workflow for generator pattern
*
* @example Basic toggle with yield* in generators
* ```typescript
* import { watch, toggleClass, click } from 'watch-selector';
*
* watch('.expandable', function* () {
* yield* click(function* () {
* // Toggle expanded state
* yield* toggleClass('expanded');
*
* // Toggle multiple classes
* yield* toggleClass('open active highlighted');
* });
* });
* ```
*
* @example Forced toggle based on conditions
* ```typescript
* watch('.theme-toggle', function* () {
* yield* click(function* () {
* const isDark = yield* hasClass('dark-mode');
*
* // Force toggle based on current state
* yield* toggleClass('dark-mode', !isDark);
* yield* toggleClass('light-mode', isDark);
* });
* });
* ```
*
* @example Accordion behavior with toggles
* ```typescript
* watch('.accordion-item', function* () {
* yield* click(function* () {
* // Close all other items
* const siblings = yield* siblings();
* for (const sibling of siblings) {
* toggleClass(sibling, 'expanded', false);
* }
*
* // Toggle current item
* yield* toggleClass('expanded');
* });
* });
* ```
*/
export function toggleClass<T extends Element = HTMLElement>(
element: T,
className: string | ClassName,
force?: boolean,
): void;
export function toggleClass(
selector: string | CSSSelector,
className: string | ClassName,
force?: boolean,
): void;
export function toggleClass(
className: string | ClassName,
force?: boolean,
): Workflow<void>;
export function toggleClass(...args: any[]): any {
// Direct element manipulation
if ((args.length === 2 || args.length === 3) && isHTMLElement(args[0])) {
const [element, className, force] = args;
const classes = String(className).split(/\s+/).filter(Boolean);
classes.forEach((cls) => {
element.classList.toggle(cls, force);
});
return;
}
// CSS selector manipulation
if ((args.length === 2 || args.length === 3) && looksLikeSelector(args[0])) {
const [selector, className, force] = args;
const classes = String(className).split(/\s+/).filter(Boolean);
const elements = resolveElements(String(selector));
elements.forEach((el) => {
classes.forEach((cls) => {
el.classList.toggle(cls, force);
});
});
return;
}
// Generator pattern
if (args.length === 1 || args.length === 2) {
const [className, force] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
const classes = String(className).split(/\s+/).filter(Boolean);
classes.forEach((cls) => {
context.element.classList.toggle(cls, force);
});
}) as Operation<void>;
})();
}
throw new Error(
`Invalid arguments for toggleClass(): ${args.length} arguments provided`,
);
}
/**
* Checks if an element has ALL specified CSS classes.
*
* Returns true only if the element contains every specified class.
* For checking if element has ANY of the classes, use multiple calls.
*
* @example Direct element manipulation
* ```typescript
* const button = document.querySelector('button');
* // Check single class
* if (hasClass(button, 'active')) {
* console.log('Button is active');
* }
* // Check multiple classes (ALL must be present)
* if (hasClass(button, 'primary large')) {
* console.log('Button is primary AND large');
* }
* ```
*
* @example CSS selector pattern
* ```typescript
* // Check first matching element
* const isVisible = hasClass('.modal', 'visible');
* // Check multiple classes
* const isReady = hasClass('.component', 'initialized loaded');
* ```
*
* @example Generator context
* ```typescript
* import { watch, hasClass, addClass, removeClass } from 'watch-selector';
*
* watch('.toggle-element', function* () {
* const wasActive = yield hasClass('active');
*
* if (wasActive) {
* yield removeClass('active');
* yield addClass('inactive');
* } else {
* yield removeClass('inactive');
* yield addClass('active');
* }
* });
* ```
*
* @param element - The element to check (direct pattern)
* @param selector - CSS selector to find element (selector pattern)
* @param className - Space-separated class names to check for
* @returns boolean indicating if ALL classes are present, or Workflow<boolean> in generator context
*/
export function hasClass(
element: HTMLElement,
className: string | ClassName,
): boolean;
/**
* Checks if an element has a specific attribute.
*
* @example Direct element
* ```typescript
* const input = document.querySelector('input');
* if (hasAttr(input, 'required')) {
* console.log('Field is required');
* }
* ```
*
* @example CSS selector pattern
* ```typescript
* const hasPlaceholder = hasAttr('input.search', 'placeholder');
* const isDisabled = hasAttr('button.submit', 'disabled');
* ```
*
* @example Generator context
* ```typescript
* import { watch, hasAttr, attr, addClass } from 'watch-selector';
*
* watch('input', function* () {
* if (yield hasAttr('required')) {
* yield addClass('required-field');
* yield attr('aria-required', 'true');
* }
* });
* ```
*
* @param element - The element to check (direct pattern)
* @param selector - CSS selector to find element (selector pattern)
* @param name - Attribute name to check for
* @returns boolean indicating if attribute exists, Workflow<boolean> in generator context
*/
export function hasClass(
selector: string | CSSSelector,
className: string | ClassName,
): boolean;
export function hasClass(className: string | ClassName): Workflow<boolean>;
export function hasClass(...args: any[]): any {
// Direct element check
if (args.length === 2 && isHTMLElement(args[0])) {
const [element, className] = args;
const classes = String(className).split(/\s+/).filter(Boolean);
return classes.every((cls) => element.classList.contains(cls));
}
// CSS selector check (checks first element)
if (args.length === 2 && looksLikeSelector(args[0])) {
const [selector, className] = args;
const element = document.querySelector(String(selector));
if (!element || !(element instanceof HTMLElement)) return false;
const classes = String(className).split(/\s+/).filter(Boolean);
return classes.every((cls) => element.classList.contains(cls));
}
// Generator pattern
if (args.length === 1) {
const [className] = args;
return (function* (): Generator<Operation<boolean>, boolean, any> {
const result = yield ((context: WatchContext) => {
const classes = String(className).split(/\s+/).filter(Boolean);
return classes.every((cls) => context.element.classList.contains(cls));
}) as Operation<boolean>;
return result;
})();
}
throw new Error(
`Invalid arguments for hasClass(): ${args.length} arguments provided`,
);
}
// ============================================================================
// Style Function
// ============================================================================
/**
* Sets or gets CSS style properties on elements with type safety.
*
* Supports setting individual properties, multiple properties via object,
* or getting computed style values. Automatically handles vendor prefixes
* and unit conversion for numeric values.
*
* @example Direct element - single property
* ```typescript
* const div = document.querySelector('.box');
* // Set single style
* style(div, 'backgroundColor', 'red');
* style(div, 'width', 100); // Automatically adds 'px'
* style(div, 'opacity', 0.5);
*
* // Get computed style
* const width = style(div, 'width'); // returns "100px"
* ```
*
* @example Direct element - multiple properties
* ```typescript
* const panel = document.querySelector('.panel');
* style(panel, {
* backgroundColor: '#f0f0f0',
* padding: 20, // becomes '20px'
* borderRadius: '8px',
* opacity: 0.9,
* display: 'flex'
* });
* ```
*
* @example CSS selector pattern
* ```typescript
* // Style all matching elements
* style('.card', 'boxShadow', '0 2px 4px rgba(0,0,0,0.1)');
*
* // Apply multiple styles
* style('.highlighted', {
* backgroundColor: 'yellow',
* fontWeight: 'bold',
* padding: 10
* });
*
* // Get style from first match
* const bgColor = style('.card', 'backgroundColor');
* ```
*
* @example Generator context with animations
* ```typescript
* import { watch, style, delay } from 'watch-selector';
*
* watch('.animate-box', function* () {
* // Fade in animation
* yield style('opacity', 0);
* yield style('transform', 'translateY(20px)');
*
* yield delay(100);
*
* yield style({
* opacity: 1,
* transform: 'translateY(0)',
* transition: 'all 0.3s ease'
* });
* });
* ```
*
* @example Responsive styling
* ```typescript
* import { watch, style, onResize } from 'watch-selector';
*
* watch('.responsive-element', function* () {
* yield onResize(function* (entry) {
* const width = entry.contentRect.width;
*
* if (width < 600) {
* yield style({ fontSize: '14px', padding: '10px' });
* } else {
* yield style({ fontSize: '18px', padding: '20px' });
* }
* });
* });
* ```
*
* @param element - The element to style (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param prop - CSS property name (camelCase or kebab-case)
* @param value - Style value (numbers auto-convert to px for applicable properties)
* @param styles - Object of property-value pairs for multiple styles
* @returns void when setting, string when getting, Workflow in generator context
*/
/**
* Manipulates inline styles on elements with support for objects and individual properties.
*
* Handles CSS property names in both camelCase and kebab-case formats.
* Automatically adds 'px' units to numeric values for applicable properties.
* Setting a value to null or empty string removes the style property.
*
* @param element - HTMLElement to style (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param prop - CSS property name or object of property-value pairs
* @param value - CSS value (string, number, or null to remove)
* @returns void when setting, string when getting single property, Workflow for generators
*
* @example Setting styles with yield* in generators
* ```typescript
* import { watch, style, click } from 'watch-selector';
*
* watch('.animated-box', function* () {
* // Set single style property
* yield* style('background-color', '#3498db');
* yield* style('padding', 20); // Auto-adds 'px'
*
* // Set multiple styles with object
* yield* style({
* width: 200, // Becomes '200px'
* height: 100, // Becomes '100px'
* backgroundColor: '#2ecc71',
* borderRadius: '8px',
* transition: 'all 0.3s ease'
* });
* });
* ```
*
* @example Dynamic styling based on state
* ```typescript
* watch('.progress-bar', function* () {
* const progress = yield* getState('progress', 0);
*
* yield* style({
* width: `${progress}%`,
* backgroundColor: progress === 100 ? '#27ae60' : '#3498db',
* transition: 'width 0.5s ease'
* });
*
* // Get computed style
* const currentWidth = yield* style('width');
* console.log('Current width:', currentWidth);
* });
* ```
*
* @example Animations with dynamic styles
* ```typescript
* watch('.floating-element', function* () {
* let position = 0;
*
* yield* onMount(function* () {
* const animate = () => {
* position += 1;
* yield* style('transform', `translateY(${Math.sin(position * 0.1) * 10}px)`);
* requestAnimationFrame(animate);
* };
* animate();
* });
* });
* ```
*
* @example Removing styles
* ```typescript
* watch('.resettable', function* () {
* yield* click(function* () {
* // Remove specific styles by setting to null
* yield* style('backgroundColor', null);
* yield* style('border', '');
*
* // Or remove multiple at once
* yield* style({
* width: null,
* height: null,
* position: null
* });
* });
* });
* ```
*/
// Style manipulation with better type inference
export function style<T extends HTMLElement = HTMLElement>(
element: T,
prop: keyof CSSStyleDeclaration | string,
value: StyleValue,
): void;
export function style<T extends HTMLElement = HTMLElement>(
element: T,
styles: Partial<CSSStyleDeclaration> | StyleObject,
): void;
export function style<T extends HTMLElement = HTMLElement>(
element: T,
prop: keyof CSSStyleDeclaration | string,
): string;
export function style(
selector: string | CSSSelector,
prop: keyof CSSStyleDeclaration | string,
value: StyleValue,
): void;
export function style(
selector: string | CSSSelector,
styles: Partial<CSSStyleDeclaration> | StyleObject,
): void;
export function style(
selector: string | CSSSelector,
prop: keyof CSSStyleDeclaration | string,
): string | null;
export function style(
prop: keyof CSSStyleDeclaration | string,
value: StyleValue,
): Workflow<void>;
export function style(
styles: Partial<CSSStyleDeclaration> | StyleObject,
): Workflow<void>;
export function style(
prop: keyof CSSStyleDeclaration | string,
): Workflow<string>;
export function style(...args: any[]): any {
const applyStyles = <E extends HTMLElement>(
element: E,
propOrStyles: StyleObject | string,
value?: StyleValue,
) => {
if (typeof propOrStyles === "object") {
Object.entries(propOrStyles).forEach(([prop, val]) => {
if (val !== null && val !== undefined) {
(element.style as any)[prop] = String(val);
} else {
(element.style as any)[prop] = "";
}
});
} else {
if (value !== null && value !== undefined) {
(element.style as any)[propOrStyles] = String(value);
} else {
(element.style as any)[propOrStyles] = "";
}
}
};
// Direct element manipulation
if (isHTMLElement(args[0])) {
const [element, propOrStyles, value] = args;
applyStyles(element, propOrStyles, value);
return;
}
// CSS selector manipulation with 3 args (selector, prop, value)
if (args.length === 3 && looksLikeSelector(args[0])) {
const [selector, prop, value] = args;
const elements = resolveElements(String(selector));
elements.forEach((el) => {
applyStyles(el, prop, value);
});
return;
}
// CSS selector manipulation with 2 args (selector, styles object)
if (
args.length === 2 &&
looksLikeSelector(args[0]) &&
typeof args[1] === "object"
) {
const [selector, styles] = args;
const elements = resolveElements(String(selector));
elements.forEach((el) => {
applyStyles(el, styles);
});
return;
}
// Generator setter with prop/value
if (
args.length === 2 &&
typeof args[0] === "string" &&
typeof args[1] !== "object"
) {
const [prop, value] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
applyStyles(context.element, prop, value);
}) as Operation<void>;
})();
}
// Generator setter with object
if (args.length === 1 && typeof args[0] === "object") {
const [styles] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
applyStyles(context.element, styles);
}) as Operation<void>;
})();
}
// Generator getter
if (args.length === 1 && typeof args[0] === "string") {
const [prop] = args;
return (function* (): Generator<Operation<string>, string, any> {
const result = yield ((context: WatchContext) => {
return getComputedStyle(context.element).getPropertyValue(prop);
}) as Operation<string>;
return result;
})();
}
throw new Error(
`Invalid arguments for style(): ${args.length} arguments provided`,
);
}
// ============================================================================
// Attribute Functions
// ============================================================================
export function attr<T extends Element = HTMLElement>(
element: T,
name: string,
value: string | number | boolean,
): void;
export function attr<T extends Element = HTMLElement>(
element: T,
attrs: AttributeObject,
): void;
export function attr<T extends Element = HTMLElement>(
element: T,
name: string,
): string | null;
export function attr(
selector: string | CSSSelector,
name: string,
value: string | number | boolean,
): void;
export function attr(
selector: string | CSSSelector,
attrs: AttributeObject,
): void;
export function attr(
selector: string | CSSSelector,
name: string,
): string | null;
export function attr(
name: string,
value: string | number | boolean,
): Workflow<void>;
export function attr(attrs: AttributeObject): Workflow<void>;
export function attr(name: string): Workflow<string | null>;
export function attr<T extends Element = HTMLElement>(...args: any[]): any {
const applyAttrs = <E extends Element>(
element: E,
nameOrAttrs: AttributeObject | string,
value?: string | number | boolean,
) => {
if (typeof nameOrAttrs === "object") {
Object.entries(nameOrAttrs).forEach(([name, val]) => {
if (val !== null && val !== undefined) {
element.setAttribute(name, String(val));
} else {
element.removeAttribute(name);
}
});
} else {
if (value !== null && value !== undefined) {
element.setAttribute(nameOrAttrs, String(value));
} else {
element.removeAttribute(nameOrAttrs);
}
}
};
// Direct element manipulation setter
if (args.length >= 2 && isElement(args[0])) {
const [element, nameOrAttrs, value] = args as [
T,
string | AttributeObject,
string | number | boolean | undefined,
];
if (typeof nameOrAttrs === "string" && value === undefined) {
// Getter pattern
return element.getAttribute(nameOrAttrs);
}
applyAttrs(element, nameOrAttrs, value as string | number | boolean);
return;
}
// CSS selector manipulation with 3 args (selector, name, value)
if (args.length === 3 && looksLikeSelector(args[0])) {
const [selector, name, value] = args;
const elements = resolveElements(String(selector));
elements.forEach((el) => {
applyAttrs(el, name, value);
});
return;
}
// CSS selector manipulation with 2 args (selector, attrs object)
if (
args.length === 2 &&
looksLikeSelector(args[0]) &&
typeof args[1] === "object"
) {
const [selector, attrs] = args;
const elements = resolveElements(String(selector));
elements.forEach((el) => {
applyAttrs(el, attrs);
});
return;
}
// Generator setter with name/value
if (
args.length === 2 &&
typeof args[0] === "string" &&
typeof args[1] !== "object"
) {
const [name, value] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
if (typeof value === "boolean") {
if (value) {
context.element.setAttribute(name, "");
} else {
context.element.removeAttribute(name);
}
} else {
context.element.setAttribute(name, String(value));
}
}) as Operation<void>;
})();
}
// Generator setter with object
if (args.length === 1 && typeof args[0] === "object") {
const [attrs] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
applyAttrs(context.element, attrs);
}) as Operation<void>;
})();
}
// Generator getter
if (args.length === 1 && typeof args[0] === "string") {
const [name] = args;
return (function* (): Generator<
Operation<string | null>,
string | null,
any
> {
const result = yield ((context: WatchContext) => {
return context.element.getAttribute(name);
}) as Operation<string | null>;
return result;
})();
}
throw new Error(
`Invalid arguments for attr(): ${args.length} arguments provided`,
);
}
export function removeAttr<T extends HTMLElement = HTMLElement>(
element: T,
name: string | string[],
): void;
export function removeAttr(
selector: string | CSSSelector,
name: string | string[],
): void;
export function removeAttr(name: string | string[]): Workflow<void>;
export function removeAttr(...args: any[]): any {
// Direct element manipulation
if (args.length === 2 && isHTMLElement(args[0])) {
const [element, name] = args;
const names = Array.isArray(name) ? name : [name];
names.forEach((n) => element.removeAttribute(n));
return;
}
// CSS selector manipulation
if (args.length === 2 && looksLikeSelector(args[0])) {
const [selector, name] = args;
const elements = resolveElements(String(selector));
elements.forEach((el) => {
el.removeAttribute(name);
});
return;
}
// Generator pattern
if (args.length === 1) {
const [name] = args;
return (function* (): Generator<Operation<void>, void, any> {
yield ((context: WatchContext) => {
const names = Array.isArray(name) ? name : [name];
names.forEach((n) => context.element.removeAttribute(n));
}) as Operation<void>;
})();
}
throw new Error(
`Invalid arguments for removeAttr(): ${args.length} arguments provided`,
);
}
export function hasAttr<T extends HTMLElement = HTMLElement>(
element: T,
name: string,
): boolean;
export function hasAttr(selector: string | CSSSelector, name: string): boolean;
export function hasAttr(name: string): Workflow<boolean>;
export function hasAttr(...args: any[]): any {
// Direct element check
if (args.length === 2 && isHTMLElement(args[0])) {
const [element, name] = args;
return element.hasAttribute(name);
}
// CSS selector check
if (args.length === 2 && looksLikeSelector(args[0])) {
const [selector, name] = args;
const element = document.querySelector(String(selector));
if (!element) return false;
return element.hasAttribute(name);
}
// Generator pattern
if (args.length === 1) {
const [name] = args;
return (function* (): Generator<Operation<boolean>, boolean, any> {
const result = yield ((context: WatchContext) => {
return context.element.hasAttribute(name);
}) as Operation<boolean>;
return result;
})();
}
throw new Error(
`Invalid arguments for hasAttr(): ${args.length} arguments provided`,
);
}
// ============================================================================
// Property Functions
// ============================================================================
/**
* Manipulates JavaScript properties on DOM elements.
*
* Properties are part of the DOM object and can be any JavaScript type (boolean, number, object, etc).
* Use this for properties like 'checked', 'disabled', 'value', 'selectedIndex', custom properties, etc.
* Properties reflect the current state, while attributes represent the initial HTML markup.
*
* @param element - HTMLElement to manipulate properties on (direct pattern)
* @param selector - CSS selector to find elements (selector pattern)
* @param name - Property name to get or set
* @param value - Property value of any type
* @returns void when setting, T when getting, Workflow for generators
*
* @example Boolean properties with yield*
* ```typescript
* import { watch, prop, click } from 'watch-selector';
*
* watch('.checkbox-wrapper', function* () {
* // Set boolean properties
* yield* prop('checked', true);
* yield* prop('disabled', false);
* yield* prop('required', true);
*
* yield* click(function* () {
* // Toggle checked state
* const isChecked = yield* prop('checked');
* yield* prop('checked', !isChecked);
*
* // Update related elements
* if (!isChecked) {
* yield* addClass('selected');
* } else {
* yield* removeClass('selected');
* }
* });
* });
* ```
*
* @example Form element properties
* ```typescript
* watch('.form-select', function* () {
* // Set selected index
* yield* prop('selectedIndex', 2);
*
* // Get current selection
* const index = yield* prop('selectedIndex');
* const selectedOption = yield* prop('selectedOptions');
*
* yield* change(function* () {
* const value = yield* prop('value');
* console.log('Selected:', value);
*
* // Enable submit button when something is selected
* const submitBtn = yield* query('.submit-btn');
* if (submitBtn) {
* prop(submitBtn, 'disabled', !value);
* }
* });
* });
* ```
*
* @example Custom properties and objects
* ```typescript
* watch('.data-container', function* () {
* // Store complex data as properties
* yield* prop('customData', {
* id: 123,
* name: 'Test Item',
* metadata: { created: new Date() }
* });
*
* // Store functions as properties
* yield* prop('validator', (value: string) => {
* return value.length > 0 && value.length < 100;
* });
*
* // Retrieve and use custom propert