import { createElementFromHtml, insertContent, sanitizeContent, type InsertableContent, } from './dom'; import { BQueryElement } from './element'; import { applyAll, getInnerSize, getOuterSize, isHTMLElement, toElementList } from './shared'; /** Handler signature for delegated events */ type DelegatedHandler = (event: Event, target: Element) => void; /** * Wrapper for multiple DOM elements. * Provides batch operations on a collection of elements with chainable API. * * This class enables jQuery-like operations across multiple elements: * - All mutating methods apply to every element in the collection * - Getter methods return data from the first element * - Supports iteration via forEach, map, filter, and reduce * * @example * ```ts * $$('.items') * .addClass('highlight') * .css({ opacity: '0.8' }) * .on('click', () => console.log('clicked')); * ``` */ export class BQueryCollection { /** * Stores delegated event handlers for cleanup via undelegate(). * Outer map: element -> (key -> (handler -> wrapper)) * Key format: `${event}:${selector}` * @internal */ private readonly delegatedHandlers = new WeakMap< Element, Map> >(); /** * Creates a new collection wrapper. * @param elements - Array of DOM elements to wrap */ constructor(public readonly elements: Element[]) {} /** * Gets the number of elements in the collection. */ get length(): number { return this.elements.length; } /** * Gets the first element in the collection, if any. * @internal */ private first(): Element | undefined { return this.elements[0]; } /** * Gets a single element as a BQueryElement wrapper. * * @param index - Zero-based index of the element * @returns BQueryElement wrapper or undefined if out of range */ eq(index: number): BQueryElement | undefined { const el = this.elements[index]; return el ? new BQueryElement(el) : undefined; } /** * Gets the first element as a BQueryElement wrapper. * * @returns BQueryElement wrapper or undefined if empty */ firstEl(): BQueryElement | undefined { return this.eq(0); } /** * Gets the last element as a BQueryElement wrapper. * * @returns BQueryElement wrapper or undefined if empty */ lastEl(): BQueryElement | undefined { return this.eq(this.elements.length - 1); } /** * Iterates over each element in the collection. * * @param callback - Function to call for each wrapped element * @returns The instance for method chaining */ each(callback: (element: BQueryElement, index: number) => void): this { this.elements.forEach((element, index) => { callback(new BQueryElement(element), index); }); return this; } /** * Maps each element to a new value. * * @param callback - Function to transform each element * @returns Array of transformed values */ map(callback: (element: Element, index: number) => T): T[] { return this.elements.map(callback); } /** * Filters elements based on a predicate. * * @param predicate - Function to test each element * @returns New BQueryCollection with matching elements */ filter(predicate: (element: Element, index: number) => boolean): BQueryCollection { return new BQueryCollection(this.elements.filter(predicate)); } /** * Reduces the collection to a single value. * * @param callback - Reducer function * @param initialValue - Initial accumulator value * @returns Accumulated result */ reduce(callback: (accumulator: T, element: Element, index: number) => T, initialValue: T): T { return this.elements.reduce(callback, initialValue); } /** * Converts the collection to an array of BQueryElement wrappers. * * @returns Array of BQueryElement instances */ toArray(): BQueryElement[] { return this.elements.map((el) => new BQueryElement(el)); } /** Add one or more classes to all elements. */ addClass(...classNames: string[]): this { applyAll(this.elements, (el) => el.classList.add(...classNames)); return this; } /** Remove one or more classes from all elements. */ removeClass(...classNames: string[]): this { applyAll(this.elements, (el) => el.classList.remove(...classNames)); return this; } /** Toggle a class on all elements. */ toggleClass(className: string, force?: boolean): this { applyAll(this.elements, (el) => el.classList.toggle(className, force)); return this; } /** * Sets an attribute on all elements or gets from first. * * @param name - Attribute name * @param value - Value to set (optional) * @returns Attribute value when getting, instance when setting */ attr(name: string, value?: string): string | this { if (value === undefined) { return this.first()?.getAttribute(name) ?? ''; } applyAll(this.elements, (el) => el.setAttribute(name, value)); return this; } /** * Removes an attribute from all elements. * * @param name - Attribute name to remove * @returns The instance for method chaining */ removeAttr(name: string): this { applyAll(this.elements, (el) => el.removeAttribute(name)); return this; } /** Toggle an attribute on all elements. */ toggleAttr(name: string, force?: boolean): this { applyAll(this.elements, (el) => { const hasAttr = el.hasAttribute(name); const shouldAdd = force ?? !hasAttr; if (shouldAdd) { el.setAttribute(name, ''); } else { el.removeAttribute(name); } }); return this; } /** * Sets text content on all elements or gets from first. * * @param value - Text to set (optional) * @returns Text content when getting, instance when setting */ text(value?: string): string | this { if (value === undefined) { return this.first()?.textContent ?? ''; } applyAll(this.elements, (el) => { el.textContent = value; }); return this; } /** * Sets sanitized HTML on all elements or gets from first. * * @param value - HTML to set (optional, will be sanitized) * @returns HTML content when getting, instance when setting */ html(value?: string): string | this { if (value === undefined) { return this.first()?.innerHTML ?? ''; } const sanitized = sanitizeContent(value); applyAll(this.elements, (el) => { el.innerHTML = sanitized; }); return this; } /** * Sets HTML on all elements without sanitization. * * @param value - Raw HTML to set * @returns The instance for method chaining * @warning Bypasses XSS protection */ htmlUnsafe(value: string): this { applyAll(this.elements, (el) => { el.innerHTML = value; }); return this; } /** Append content to all elements. */ append(content: InsertableContent): this { this.insertAll(content, 'beforeend'); return this; } /** Prepend content to all elements. */ prepend(content: InsertableContent): this { this.insertAll(content, 'afterbegin'); return this; } /** Insert content before all elements. */ before(content: InsertableContent): this { this.insertAll(content, 'beforebegin'); return this; } /** Insert content after all elements. */ after(content: InsertableContent): this { this.insertAll(content, 'afterend'); return this; } /** * Gets or sets CSS styles on all elements. * When getting, returns the computed style value from the first element. * * @param property - Property name or object of properties * @param value - Value when setting single property * @returns The computed style value when getting, instance when setting */ css(property: string): string; css(property: string, value: string): this; css(property: Record): this; css(property: string | Record, value?: string): string | this { if (typeof property === 'string') { if (value !== undefined) { applyAll(this.elements, (el) => { (el as HTMLElement).style.setProperty(property, value); }); return this; } const first = this.first(); if (!first) { return ''; } const view = first.ownerDocument?.defaultView; if (!view || typeof view.getComputedStyle !== 'function') { return ''; } return view.getComputedStyle(first).getPropertyValue(property); } applyAll(this.elements, (el) => { for (const [key, val] of Object.entries(property)) { (el as HTMLElement).style.setProperty(key, val); } }); return this; } /** Wrap each element with a wrapper element or tag. */ wrap(wrapper: string | Element): this { this.elements.forEach((el, index) => { const wrapperEl = typeof wrapper === 'string' ? document.createElement(wrapper) : index === 0 ? wrapper : (wrapper.cloneNode(true) as Element); el.parentNode?.insertBefore(wrapperEl, el); wrapperEl.appendChild(el); }); return this; } /** * Remove the parent element of each element, keeping the elements in place. * * **Important**: This method unwraps ALL children of each parent element, * not just the elements in the collection. If you call `unwrap()` on a * collection containing only some children of a parent, all siblings will * also be unwrapped. This behavior is consistent with jQuery's `.unwrap()`. * * @returns The collection for chaining * * @example * ```ts * // HTML:
AB
* const spans = $$('span'); * spans.unwrap(); // Removes
, both spans move to
* // Result:
AB
* ``` */ unwrap(): this { // Collect unique parent elements to avoid removing the same parent multiple times. const parents = new Set(); for (const el of this.elements) { if (el.parentElement) { parents.add(el.parentElement); } } // Unwrap each parent once: move all children out, then remove the wrapper. parents.forEach((parent) => { const grandParent = parent.parentNode; if (!grandParent) return; while (parent.firstChild) { grandParent.insertBefore(parent.firstChild, parent); } parent.remove(); }); return this; } /** Replace each element with provided content. */ replaceWith(content: string | Element): BQueryCollection { const replacements: Element[] = []; this.elements.forEach((el, index) => { const replacement = typeof content === 'string' ? createElementFromHtml(content) : index === 0 ? content : (content.cloneNode(true) as Element); el.replaceWith(replacement); replacements.push(replacement); }); return new BQueryCollection(replacements); } /** * Removes all elements from the DOM while keeping the wrapped nodes available * for later reuse. * * @returns The instance for method chaining */ detach(): this { return this.remove(); } /** * Gets the zero-based sibling index of the first element in the collection. * * @returns Index of the first element, or -1 when unavailable */ index(): number { const first = this.first(); if (!first?.parentElement) { return -1; } return Array.from(first.parentElement.children).indexOf(first); } /** * Returns the child nodes of the first element, including text nodes and comments. * * @returns Array of child nodes from the first element */ contents(): ChildNode[] { return Array.from(this.first()?.childNodes ?? []); } /** * Gets the offset parent of the first element in the collection. * * @returns Offset parent element, or null when unavailable */ offsetParent(): Element | null { const first = this.first(); return isHTMLElement(first) ? first.offsetParent : null; } /** * Gets the position of the first element relative to its offset parent. * * @returns Position object with top and left coordinates */ position(): { top: number; left: number } { const first = this.first(); if (!isHTMLElement(first)) { return { top: 0, left: 0 }; } return { top: first.offsetTop, left: first.offsetLeft, }; } /** * Gets the inner width of the first element (content + padding, excluding border). * * @returns Inner width in pixels, or 0 when the collection is empty */ innerWidth(): number { return getInnerSize(this.first(), 'width'); } /** * Gets the inner height of the first element (content + padding, excluding border). * * @returns Inner height in pixels, or 0 when the collection is empty */ innerHeight(): number { return getInnerSize(this.first(), 'height'); } /** * Gets the outer width of the first element, optionally including margins. * * @param includeMargin - When true, include horizontal margins * @returns Outer width in pixels */ outerWidth(includeMargin: boolean = false): number { return getOuterSize(this.first(), 'width', includeMargin); } /** * Gets the outer height of the first element, optionally including margins. * * @param includeMargin - When true, include vertical margins * @returns Outer height in pixels */ outerHeight(includeMargin: boolean = false): number { return getOuterSize(this.first(), 'height', includeMargin); } /** * Shows all elements. * * @param display - Optional display value (default: '') * @returns The instance for method chaining */ show(display: string = ''): this { applyAll(this.elements, (el) => { el.removeAttribute('hidden'); (el as HTMLElement).style.display = display; }); return this; } /** * Hides all elements. * * @returns The instance for method chaining */ hide(): this { applyAll(this.elements, (el) => { (el as HTMLElement).style.display = 'none'; }); return this; } /** * Adds an event listener to all elements. * * @param event - Event type * @param handler - Event handler * @returns The instance for method chaining */ on(event: string, handler: EventListenerOrEventListenerObject): this { applyAll(this.elements, (el) => el.addEventListener(event, handler)); return this; } /** * Adds a one-time event listener to all elements. * * @param event - Event type * @param handler - Event handler * @returns The instance for method chaining */ once(event: string, handler: EventListener): this { applyAll(this.elements, (el) => el.addEventListener(event, handler, { once: true })); return this; } /** * Removes an event listener from all elements. * * @param event - Event type * @param handler - The handler to remove * @returns The instance for method chaining */ off(event: string, handler: EventListenerOrEventListenerObject): this { applyAll(this.elements, (el) => el.removeEventListener(event, handler)); return this; } /** * Triggers a custom event on all elements. * * @param event - Event type * @param detail - Optional event detail * @returns The instance for method chaining */ trigger(event: string, detail?: unknown): this { applyAll(this.elements, (el) => { el.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true })); }); return this; } /** * Adds a delegated event listener to all elements. * Events are delegated to matching descendants. * * Use `undelegate()` to remove the listener later. * * @param event - Event type to listen for * @param selector - CSS selector to match against event targets * @param handler - Event handler function * @returns The instance for method chaining * * @example * ```ts * const handler = (e, target) => console.log('Clicked:', target.textContent); * $$('.container').delegate('click', '.item', handler); * * // Later, remove the delegated listener: * $$('.container').undelegate('click', '.item', handler); * ``` */ delegate( event: string, selector: string, handler: (event: Event, target: Element) => void ): this { const key = `${event}:${selector}`; applyAll(this.elements, (el) => { const wrapper: EventListener = (e: Event) => { const target = (e.target as Element).closest(selector); if (target && el.contains(target)) { handler(e, target); } }; // Get or create the handler maps for this element if (!this.delegatedHandlers.has(el)) { this.delegatedHandlers.set(el, new Map()); } const elementHandlers = this.delegatedHandlers.get(el)!; if (!elementHandlers.has(key)) { elementHandlers.set(key, new Map()); } elementHandlers.get(key)!.set(handler, wrapper); el.addEventListener(event, wrapper); }); return this; } /** * Removes a delegated event listener previously added with `delegate()`. * * @param event - Event type that was registered * @param selector - CSS selector that was used * @param handler - The original handler function passed to delegate() * @returns The instance for method chaining * * @example * ```ts * const handler = (e, target) => console.log('Clicked:', target.textContent); * $$('.container').delegate('click', '.item', handler); * * // Remove the delegated listener: * $$('.container').undelegate('click', '.item', handler); * ``` */ undelegate( event: string, selector: string, handler: (event: Event, target: Element) => void ): this { const key = `${event}:${selector}`; applyAll(this.elements, (el) => { const elementHandlers = this.delegatedHandlers.get(el); if (!elementHandlers) return; const handlers = elementHandlers.get(key); if (!handlers) return; const wrapper = handlers.get(handler); if (wrapper) { el.removeEventListener(event, wrapper); handlers.delete(handler); // Clean up empty maps if (handlers.size === 0) { elementHandlers.delete(key); } if (elementHandlers.size === 0) { this.delegatedHandlers.delete(el); } } }); return this; } /** * Finds all descendant elements matching the selector across all elements * in the collection. Returns a new BQueryCollection with the results. * * @param selector - CSS selector to match * @returns A new BQueryCollection with all matching descendants * * @example * ```ts * $$('.container').find('.item').addClass('highlight'); * ``` */ find(selector: string): BQueryCollection { const seen = new Set(); const results: Element[] = []; for (const el of this.elements) { const found = el.querySelectorAll(selector); for (let i = 0; i < found.length; i++) { if (!seen.has(found[i])) { seen.add(found[i]); results.push(found[i]); } } } return new BQueryCollection(results); } /** * Gets the closest element or ancestor matching a selector for each element in * the collection, including the element itself. Duplicates are removed from the * result. * * @param selector - CSS selector to match * @returns A new BQueryCollection with matching elements or ancestors * * @example * ```ts * $$('.item').closest('.container'); * ``` */ closest(selector: string): BQueryCollection { const seen = new Set(); const results: Element[] = []; for (const el of this.elements) { const match = el.closest(selector); if (match && !seen.has(match)) { seen.add(match); results.push(match); } } return new BQueryCollection(results); } /** * Gets the parent element of each element in the collection. * Duplicates are removed (e.g. siblings sharing a parent). * * @returns A new BQueryCollection with unique parent elements * * @example * ```ts * $$('.item').parent().addClass('has-items'); * ``` */ parent(): BQueryCollection { const seen = new Set(); const results: Element[] = []; for (const el of this.elements) { const p = el.parentElement; if (p && !seen.has(p)) { seen.add(p); results.push(p); } } return new BQueryCollection(results); } /** * Gets the direct children of every element in the collection. * Duplicates are removed from the result. * * @returns A new BQueryCollection with child elements * * @example * ```ts * $$('.list').children().addClass('child'); * ``` */ children(): BQueryCollection { const seen = new Set(); const results: Element[] = []; for (const el of this.elements) { for (const child of Array.from(el.children)) { if (!seen.has(child)) { seen.add(child); results.push(child); } } } return new BQueryCollection(results); } /** * Gets all siblings of every element in the collection (excluding the * elements themselves). Duplicates are removed. * * @returns A new BQueryCollection with sibling elements * * @example * ```ts * $$('.active').siblings().removeClass('active'); * ``` */ siblings(): BQueryCollection { const selfSet = new Set(this.elements); const seen = new Set(); const results: Element[] = []; for (const el of this.elements) { const parent = el.parentElement; if (!parent) continue; for (const sibling of Array.from(parent.children)) { if (!selfSet.has(sibling) && !seen.has(sibling)) { seen.add(sibling); results.push(sibling); } } } return new BQueryCollection(results); } /** * Gets the next sibling element of each element in the collection. * Elements without a next sibling are skipped. * * @returns A new BQueryCollection with next sibling elements * * @example * ```ts * $$('.current').next().addClass('upcoming'); * ``` */ next(): BQueryCollection { const seen = new Set(); const results: Element[] = []; for (const el of this.elements) { const n = el.nextElementSibling; if (n && !seen.has(n)) { seen.add(n); results.push(n); } } return new BQueryCollection(results); } /** * Gets the previous sibling element of each element in the collection. * Elements without a previous sibling are skipped. * * @returns A new BQueryCollection with previous sibling elements * * @example * ```ts * $$('.current').prev().addClass('previous'); * ``` */ prev(): BQueryCollection { const seen = new Set(); const results: Element[] = []; for (const el of this.elements) { const p = el.previousElementSibling; if (p && !seen.has(p)) { seen.add(p); results.push(p); } } return new BQueryCollection(results); } /** * Removes all elements from the DOM. * * @returns The instance for method chaining */ remove(): this { applyAll(this.elements, (el) => el.remove()); return this; } /** * Clears all child nodes from all elements. * * @returns The instance for method chaining */ empty(): this { applyAll(this.elements, (el) => { el.innerHTML = ''; }); return this; } /** @internal */ private insertAll(content: InsertableContent, position: InsertPosition): void { if (typeof content === 'string') { // Sanitize once and reuse for all elements const sanitized = sanitizeContent(content); applyAll(this.elements, (el) => { el.insertAdjacentHTML(position, sanitized); }); return; } const elements = toElementList(content); this.elements.forEach((el, index) => { const nodes = index === 0 ? elements : elements.map((node) => node.cloneNode(true) as Element); insertContent(el, nodes, position); }); } }