import { createElementFromHtml, insertContent, setHtml } from './dom';
import { getInnerSize, getOuterSize, isHTMLElement } from './shared';
import { isPrototypePollutionKey } from './utils/object';
/**
* Wrapper for a single DOM element.
* Provides a chainable, jQuery-like API for DOM manipulation.
*
* This class encapsulates a DOM element and provides methods for:
* - Class manipulation (addClass, removeClass, toggleClass)
* - Attribute and property access (attr, prop, data)
* - Content manipulation (text, html, append, prepend)
* - Style manipulation (css)
* - Event handling (on, off, once, trigger)
* - DOM traversal (find, closest, parent, children, siblings)
*
* All mutating methods return `this` for method chaining.
*
* @example
* ```ts
* $('#button')
* .addClass('active')
* .css({ color: 'blue' })
* .on('click', () => console.log('clicked'));
* ```
*/
/** Handler signature for delegated events */
type DelegatedHandler = (event: Event, target: Element) => void;
type SerializableFormControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
const isSerializableFormControl = (element: Element): element is SerializableFormControl => {
const tagName = element.tagName.toLowerCase();
return tagName === 'input' || tagName === 'textarea' || tagName === 'select';
};
const collectFormEntries = (form: HTMLFormElement): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
const elementCtor = form.ownerDocument.defaultView?.Element ?? Element;
for (const control of Array.from(form.elements)) {
if (!(control instanceof elementCtor) || !isSerializableFormControl(control)) {
continue;
}
const name = control.name;
if (!name || control.disabled || isPrototypePollutionKey(name)) {
continue;
}
if (control.tagName.toLowerCase() === 'input') {
const input = control as HTMLInputElement;
const type = input.type.toLowerCase();
if (type === 'checkbox' || type === 'radio') {
if (input.checked) {
entries.push([name, input.value]);
}
continue;
}
if (
type === 'file' ||
type === 'submit' ||
type === 'button' ||
type === 'reset' ||
type === 'image'
) {
continue;
}
entries.push([name, input.value]);
continue;
}
if (control.tagName.toLowerCase() === 'select') {
const select = control as HTMLSelectElement;
if (select.multiple) {
for (const option of Array.from(select.selectedOptions)) {
entries.push([name, option.value]);
}
} else {
entries.push([name, select.value]);
}
continue;
}
entries.push([name, (control as HTMLTextAreaElement).value]);
}
return entries;
};
const getFormEntries = (form: HTMLFormElement): Array<[string, string]> => {
if (typeof FormData === 'function') {
try {
const entries: Array<[string, string]> = [];
for (const [key, value] of new FormData(form).entries()) {
if (isPrototypePollutionKey(key) || typeof value !== 'string') {
continue;
}
entries.push([key, value]);
}
// Some environments expose FormData(form) but fail to populate entries for
// successful controls. Fall back to manual collection only in that zero-entry case.
return entries.length > 0 ? entries : collectFormEntries(form);
} catch {
// Fall back to manual collection when FormData is unavailable for this form
// or the environment does not fully support constructing it.
}
}
return collectFormEntries(form);
};
export class BQueryElement {
/**
* Stores delegated event handlers for cleanup via undelegate().
* Key format: `${event}:${selector}`
* @internal
*/
private readonly delegatedHandlers = new Map>();
/**
* Creates a new BQueryElement wrapper.
* @param element - The DOM element to wrap
*/
constructor(private readonly element: Element) {}
/**
* Exposes the raw DOM element when direct access is needed.
* Use sparingly; prefer the wrapper methods for consistency.
*/
get raw(): Element {
return this.element;
}
/**
* Exposes the underlying DOM element.
* Provided for spec compatibility and read-only access.
*/
get node(): Element {
return this.element;
}
/** Add one or more classes. */
addClass(...classNames: string[]): this {
this.element.classList.add(...classNames);
return this;
}
/** Remove one or more classes. */
removeClass(...classNames: string[]): this {
this.element.classList.remove(...classNames);
return this;
}
/** Toggle a class by name. */
toggleClass(className: string, force?: boolean): this {
this.element.classList.toggle(className, force);
return this;
}
/** Get or set an attribute. */
attr(name: string, value?: string): string | this {
if (value === undefined) {
return this.element.getAttribute(name) ?? '';
}
this.element.setAttribute(name, value);
return this;
}
/** Remove an attribute. */
removeAttr(name: string): this {
this.element.removeAttribute(name);
return this;
}
/** Toggle an attribute on/off. */
toggleAttr(name: string, force?: boolean): this {
const hasAttr = this.element.hasAttribute(name);
const shouldAdd = force ?? !hasAttr;
if (shouldAdd) {
this.element.setAttribute(name, '');
} else {
this.element.removeAttribute(name);
}
return this;
}
/** Get or set a property. */
prop(name: T, value?: Element[T]): Element[T] | this {
if (value === undefined) {
return this.element[name];
}
this.element[name] = value;
return this;
}
/** Read or write data attributes in camelCase. */
data(name: string, value?: string): string | this {
const key = name.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
if (value === undefined) {
return this.element.getAttribute(`data-${key}`) ?? '';
}
this.element.setAttribute(`data-${key}`, value);
return this;
}
/** Get or set text content. */
text(value?: string): string | this {
if (value === undefined) {
return this.element.textContent ?? '';
}
this.element.textContent = value;
return this;
}
/** Set HTML content using a sanitized string. */
/**
* Sets sanitized HTML content on the element.
* Uses the security module to sanitize input and prevent XSS attacks.
*
* @param value - The HTML string to set (will be sanitized)
* @returns The instance for method chaining
*
* @example
* ```ts
* $('#content').html('Hello');
* ```
*/
html(value: string): this {
setHtml(this.element, value);
return this;
}
/**
* Sets HTML content without sanitization.
* Use only when you trust the HTML source completely.
*
* @param value - The raw HTML string to set
* @returns The instance for method chaining
*
* @warning This method bypasses XSS protection. Use with caution.
*/
htmlUnsafe(value: string): this {
this.element.innerHTML = value;
return this;
}
/**
* Gets or sets CSS styles on the element.
*
* @param property - A CSS property name or an object of property-value pairs
* @param value - The value when setting a single property
* @returns The computed style value when getting a single property, or the instance for method chaining when setting
*
* @example
* ```ts
* // Get a computed style value
* const color = $('#box').css('color');
*
* // Set a single property
* $('#box').css('color', 'red');
*
* // Set multiple properties
* $('#box').css({ color: 'red', 'font-size': '16px' });
* ```
*/
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) {
(this.element as HTMLElement).style.setProperty(property, value);
return this;
}
const view = this.element.ownerDocument?.defaultView;
if (!view || typeof view.getComputedStyle !== 'function') {
return '';
}
return view.getComputedStyle(this.element).getPropertyValue(property);
}
for (const [key, val] of Object.entries(property)) {
(this.element as HTMLElement).style.setProperty(key, val);
}
return this;
}
/**
* Appends HTML or elements to the end of the element.
*
* @param content - HTML string or element(s) to append
* @returns The instance for method chaining
*/
append(content: string | Element | Element[]): this {
this.insertContent(content, 'beforeend');
return this;
}
/**
* Prepends HTML or elements to the beginning of the element.
*
* @param content - HTML string or element(s) to prepend
* @returns The instance for method chaining
*/
prepend(content: string | Element | Element[]): this {
this.insertContent(content, 'afterbegin');
return this;
}
/**
* Inserts content before this element.
*
* @param content - HTML string or element(s) to insert
* @returns The instance for method chaining
*/
before(content: string | Element | Element[]): this {
this.insertContent(content, 'beforebegin');
return this;
}
/**
* Inserts content after this element.
*
* @param content - HTML string or element(s) to insert
* @returns The instance for method chaining
*/
after(content: string | Element | Element[]): this {
this.insertContent(content, 'afterend');
return this;
}
/**
* Wraps the element with the specified wrapper element or tag.
*
* @param wrapper - Tag name string or Element to wrap with
* @returns The instance for method chaining
*
* @example
* ```ts
* $('#content').wrap('div'); // Wraps with
* $('#content').wrap(document.createElement('section'));
* ```
*/
wrap(wrapper: string | Element): this {
const wrapperEl = typeof wrapper === 'string' ? document.createElement(wrapper) : wrapper;
this.element.parentNode?.insertBefore(wrapperEl, this.element);
wrapperEl.appendChild(this.element);
return this;
}
/**
* Removes the parent element, keeping this element in its place.
* Essentially the opposite of wrap().
*
* **Important**: This method only moves the current element out of its parent
* before removing the parent. Any sibling elements will be removed along with
* the parent. For unwrapping multiple siblings, use a collection: `$$(siblings).unwrap()`.
*
* @returns The instance for method chaining
*
* @example
* ```ts
* // Before:
Hello
* $('#text').unwrap();
* // After: Hello
* ```
*/
unwrap(): this {
const parent = this.element.parentElement;
if (parent && parent.parentNode) {
parent.parentNode.insertBefore(this.element, parent);
parent.remove();
}
return this;
}
/**
* Replaces this element with new content.
*
* @param content - HTML string (sanitized) or Element to replace with
* @returns A new BQueryElement wrapping the replacement element
*
* @example
* ```ts
* const newEl = $('#old').replaceWith('
Replaced
');
* ```
*/
replaceWith(content: string | Element): BQueryElement {
const newEl = typeof content === 'string' ? createElementFromHtml(content) : content;
this.element.replaceWith(newEl);
return new BQueryElement(newEl);
}
/**
* Removes the element from the DOM while keeping the wrapped node available
* for later reuse.
*
* @returns The instance for method chaining
*
* @example
* ```ts
* const item = $('#item').detach();
* document.body.appendChild(item.raw);
* ```
*/
detach(): this {
return this.remove();
}
/**
* Gets the zero-based index of the element among its element siblings.
*
* @returns Index within the parent element, or -1 when detached
*
* @example
* ```ts
* const index = $('#item').index();
* ```
*/
index(): number {
const parent = this.element.parentElement;
if (!parent) {
return -1;
}
return Array.from(parent.children).indexOf(this.element);
}
/**
* Returns all child nodes, including text nodes and comments.
*
* @returns Array of child nodes
*
* @example
* ```ts
* const nodes = $('#content').contents();
* ```
*/
contents(): ChildNode[] {
return Array.from(this.element.childNodes);
}
/**
* Gets the nearest positioned ancestor used for offset calculations.
*
* @returns The offset parent element, or null when unavailable
*
* @example
* ```ts
* const parent = $('#item').offsetParent();
* ```
*/
offsetParent(): Element | null {
return isHTMLElement(this.element) ? this.element.offsetParent : null;
}
/**
* Gets the current position relative to the offset parent.
*
* @returns Position object with top and left coordinates
*
* @example
* ```ts
* const { top, left } = $('#item').position();
* ```
*/
position(): { top: number; left: number } {
if (!isHTMLElement(this.element)) {
return { top: 0, left: 0 };
}
const el = this.element;
return {
top: el.offsetTop,
left: el.offsetLeft,
};
}
/**
* Gets the inner width of the element (content + padding, excluding border).
*
* This corresponds to the element's `clientWidth` and mirrors jQuery's
* `innerWidth()` method.
*
* @returns Inner width in pixels, or 0 for non-HTML elements
*
* @example
* ```ts
* const innerW = $('#panel').innerWidth();
* ```
*/
innerWidth(): number {
return getInnerSize(this.element, 'width');
}
/**
* Gets the inner height of the element (content + padding, excluding border).
*
* This corresponds to the element's `clientHeight` and mirrors jQuery's
* `innerHeight()` method.
*
* @returns Inner height in pixels, or 0 for non-HTML elements
*
* @example
* ```ts
* const innerH = $('#panel').innerHeight();
* ```
*/
innerHeight(): number {
return getInnerSize(this.element, 'height');
}
/**
* Gets the outer width of the element, optionally including margins.
*
* @param includeMargin - When true, include horizontal margins
* @returns Outer width in pixels
*
* @example
* ```ts
* const width = $('#panel').outerWidth();
* const widthWithMargin = $('#panel').outerWidth(true);
* ```
*/
outerWidth(includeMargin: boolean = false): number {
return getOuterSize(this.element, 'width', includeMargin);
}
/**
* Gets the outer height of the element, optionally including margins.
*
* @param includeMargin - When true, include vertical margins
* @returns Outer height in pixels
*
* @example
* ```ts
* const height = $('#panel').outerHeight();
* const heightWithMargin = $('#panel').outerHeight(true);
* ```
*/
outerHeight(includeMargin: boolean = false): number {
return getOuterSize(this.element, 'height', includeMargin);
}
/**
* Scrolls the element into view with configurable behavior.
*
* @param options - ScrollIntoView options or boolean for legacy behavior
* @returns The instance for method chaining
*
* @example
* ```ts
* $('#section').scrollTo(); // Smooth scroll
* $('#section').scrollTo({ behavior: 'instant', block: 'start' });
* ```
*/
scrollTo(options: ScrollIntoViewOptions | boolean = { behavior: 'smooth' }): this {
this.element.scrollIntoView(options);
return this;
}
/**
* Removes the element from the DOM.
*
* @returns The instance for method chaining (though element is now detached)
*/
remove(): this {
this.element.remove();
return this;
}
/**
* Clears all child nodes from the element.
*
* @returns The instance for method chaining
*/
empty(): this {
this.element.innerHTML = '';
return this;
}
/**
* Clones the element, optionally with all descendants.
*
* @param deep - If true, clone all descendants (default: true)
* @returns A new BQueryElement wrapping the cloned element
*/
clone(deep: boolean = true): BQueryElement {
return new BQueryElement(this.element.cloneNode(deep) as Element);
}
/**
* Finds all descendant elements matching the selector.
*
* @param selector - CSS selector to match
* @returns Array of matching elements
*/
find(selector: string): Element[] {
return Array.from(this.element.querySelectorAll(selector));
}
/**
* Finds the first descendant element matching the selector.
*
* @param selector - CSS selector to match
* @returns The first matching element or null
*/
findOne(selector: string): Element | null {
return this.element.querySelector(selector);
}
/**
* Finds the closest ancestor matching the selector.
*
* @param selector - CSS selector to match
* @returns The matching ancestor or null
*/
closest(selector: string): Element | null {
return this.element.closest(selector);
}
/**
* Gets the parent element.
*
* @returns The parent element or null
*/
parent(): Element | null {
return this.element.parentElement;
}
/**
* Gets all child elements.
*
* @returns Array of child elements
*/
children(): Element[] {
return Array.from(this.element.children);
}
/**
* Gets all sibling elements.
*
* @returns Array of sibling elements (excluding this element)
*/
siblings(): Element[] {
const parent = this.element.parentElement;
if (!parent) return [];
return Array.from(parent.children).filter((child) => child !== this.element);
}
/**
* Gets the next sibling element.
*
* @returns The next sibling element or null
*/
next(): Element | null {
return this.element.nextElementSibling;
}
/**
* Gets the previous sibling element.
*
* @returns The previous sibling element or null
*/
prev(): Element | null {
return this.element.previousElementSibling;
}
/**
* Adds an event listener.
*
* @param event - Event type to listen for
* @param handler - Event handler function
* @returns The instance for method chaining
*/
on(event: string, handler: EventListenerOrEventListenerObject): this {
this.element.addEventListener(event, handler);
return this;
}
/**
* Adds a one-time event listener that removes itself after firing.
*
* @param event - Event type to listen for
* @param handler - Event handler function
* @returns The instance for method chaining
*/
once(event: string, handler: EventListener): this {
this.element.addEventListener(event, handler, { once: true });
return this;
}
/**
* Removes an event listener.
*
* @param event - Event type
* @param handler - The handler to remove
* @returns The instance for method chaining
*/
off(event: string, handler: EventListenerOrEventListenerObject): this {
this.element.removeEventListener(event, handler);
return this;
}
/**
* Triggers a custom event on the element.
*
* @param event - Event type to trigger
* @param detail - Optional detail data to include with the event
* @returns The instance for method chaining
*/
trigger(event: string, detail?: unknown): this {
this.element.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, cancelable: true }));
return this;
}
/**
* Adds a delegated event listener that only triggers for matching descendants.
* More efficient than adding listeners to many elements individually.
*
* 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, receives the matched element as context
* @returns The instance for method chaining
*
* @example
* ```ts
* // Instead of adding listeners to each button:
* const handler = (e, target) => console.log('Clicked:', target.textContent);
* $('#list').delegate('click', '.item', handler);
*
* // Later, remove the delegated listener:
* $('#list').undelegate('click', '.item', handler);
* ```
*/
delegate(
event: string,
selector: string,
handler: (event: Event, target: Element) => void
): this {
const key = `${event}:${selector}`;
const wrapper: EventListener = (e: Event) => {
const target = (e.target as Element).closest(selector);
if (target && this.element.contains(target)) {
handler(e, target);
}
};
// Store the wrapper so it can be removed later
if (!this.delegatedHandlers.has(key)) {
this.delegatedHandlers.set(key, new Map());
}
this.delegatedHandlers.get(key)!.set(handler, wrapper);
this.element.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);
* $('#list').delegate('click', '.item', handler);
*
* // Remove the delegated listener:
* $('#list').undelegate('click', '.item', handler);
* ```
*/
undelegate(
event: string,
selector: string,
handler: (event: Event, target: Element) => void
): this {
const key = `${event}:${selector}`;
const handlers = this.delegatedHandlers.get(key);
if (handlers) {
const wrapper = handlers.get(handler);
if (wrapper) {
this.element.removeEventListener(event, wrapper);
handlers.delete(handler);
// Clean up empty maps
if (handlers.size === 0) {
this.delegatedHandlers.delete(key);
}
}
}
return this;
}
/**
* Checks if the element matches a CSS selector.
*
* @param selector - CSS selector to match against
* @returns True if the element matches the selector
*/
matches(selector: string): boolean {
return this.element.matches(selector);
}
/**
* Alias for `matches()`. Checks if the element matches a CSS selector.
*
* @param selector - CSS selector to match against
* @returns True if the element matches the selector
*
* @example
* ```ts
* if ($('#el').is('.active')) {
* console.log('Element is active');
* }
* ```
*/
is(selector: string): boolean {
return this.matches(selector);
}
/**
* Checks if the element has a specific class.
*
* @param className - Class name to check
* @returns True if the element has the class
*/
hasClass(className: string): boolean {
return this.element.classList.contains(className);
}
/**
* Shows the element by removing the hidden attribute and setting display.
*
* @param display - Optional display value (default: '')
* @returns The instance for method chaining
*/
show(display: string = ''): this {
this.element.removeAttribute('hidden');
(this.element as HTMLElement).style.display = display;
return this;
}
/**
* Hides the element by setting display to 'none'.
*
* @returns The instance for method chaining
*/
hide(): this {
(this.element as HTMLElement).style.display = 'none';
return this;
}
/**
* Toggles the visibility of the element.
*
* @param force - Optional force show (true) or hide (false)
* @returns The instance for method chaining
*/
toggle(force?: boolean): this {
const isHidden = (this.element as HTMLElement).style.display === 'none';
const shouldShow = force ?? isHidden;
return shouldShow ? this.show() : this.hide();
}
/**
* Focuses the element.
*
* @returns The instance for method chaining
*/
focus(): this {
(this.element as HTMLElement).focus();
return this;
}
/**
* Blurs (unfocuses) the element.
*
* @returns The instance for method chaining
*/
blur(): this {
(this.element as HTMLElement).blur();
return this;
}
/**
* Gets or sets the value of form elements.
*
* @param newValue - Optional value to set
* @returns The current value when getting, or the instance when setting
*/
val(newValue?: string): string | this {
const input = this.element as HTMLInputElement;
if (newValue === undefined) {
return input.value ?? '';
}
input.value = newValue;
return this;
}
/**
* Serializes form data to a plain object.
* Only works on form elements; returns empty object for non-forms.
*
* For security hardening, the returned object uses a null prototype,
* so inherited members like `hasOwnProperty` are not available directly.
* Prefer `Object.keys()` or `Object.prototype.hasOwnProperty.call(...)`
* when checking for fields on the serialized result.
*
* @returns Object with form field names as keys and values
*
* @example
* ```ts
* // For a form with
* const data = $('#myForm').serialize();
* // { email: 'test@example.com' }
* Object.prototype.hasOwnProperty.call(data, 'email'); // true
* ```
*/
serialize(): Record {
const form = this.element as HTMLFormElement;
if (form.tagName.toLowerCase() !== 'form') {
return {};
}
const result = Object.create(null) as Record;
for (const [key, value] of getFormEntries(form)) {
if (Object.prototype.hasOwnProperty.call(result, key)) {
// Handle multiple values (e.g., checkboxes)
const existing = result[key];
if (Array.isArray(existing)) {
existing.push(value);
} else {
result[key] = [existing, value];
}
} else {
result[key] = value;
}
}
return result;
}
/**
* Serializes form data to a URL-encoded query string.
*
* @returns URL-encoded string suitable for form submission
*
* @example
* ```ts
* const queryString = $('#myForm').serializeString();
* // 'email=test%40example.com&name=John'
* ```
*/
serializeString(): string {
const form = this.element as HTMLFormElement;
if (form.tagName.toLowerCase() !== 'form') {
return '';
}
const params = new URLSearchParams();
for (const [key, value] of getFormEntries(form)) {
params.append(key, value);
}
return params.toString();
}
/**
* Gets the bounding client rectangle of the element.
*
* @returns The element's bounding rectangle
*/
rect(): DOMRect {
return this.element.getBoundingClientRect();
}
/**
* Gets the offset dimensions (width, height, top, left).
*
* @returns Object with offset dimensions
*/
offset(): { width: number; height: number; top: number; left: number } {
const el = this.element as HTMLElement;
return {
width: el.offsetWidth,
height: el.offsetHeight,
top: el.offsetTop,
left: el.offsetLeft,
};
}
/**
* Internal method to insert content at a specified position.
* @internal
*/
private insertContent(content: string | Element | Element[], position: InsertPosition) {
insertContent(this.element, content, position);
}
}