/** * Shared helpers for element wrappers. */ export type ElementList = Element[]; /** * Normalize a CSS property name for the CSSOM `setProperty()` / * `getPropertyValue()` calls, which only understand the hyphenated form. * * `css({ fontSize: '18px' })` — the jQuery spelling, and the one the * migration guide shows — was otherwise ignored without any error. Custom * properties (`--brand`) are case-sensitive and passed through untouched; * vendor prefixes follow the DOM spelling (`WebkitTransition`, * `webkitTransition`, `msTransform` → `-webkit-transition`, `-ms-transform`). * Names that already contain a hyphen are passed through unchanged. * @internal */ export const toCssPropertyName = (name: string): string => { if (name.startsWith('--')) return name; if (name === 'cssFloat') return 'float'; // Already hyphenated (possibly upper-cased, which CSSOM lowercases itself) // or plain lowercase: leave it for setProperty()/getPropertyValue(). if (name.includes('-') || !/[A-Z]/.test(name)) return name; const prefixed = /^(?:ms|webkit|moz)[A-Z]/.test(name) ? `-${name}` : name; return prefixed.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); }; /** * Inline `display` values stashed by `hide()`, so `show()` can restore * `display: flex` (or any other inline value) instead of clearing it. * @internal */ const displayBeforeHide = new WeakMap(); /** * Whether an element is hidden by one of the mechanisms `show()` undoes: * an inline `display: none`, or the `hidden` attribute when no inline * `display` overrides it (an inline `display: flex` beats `[hidden]`). * `hidden="until-found"` hides through `content-visibility`, so it hides the * content whatever the inline `display` is. * @internal */ export const isElementHidden = (el: Element): boolean => { const display = (el as HTMLElement).style?.display; const hidden = el.getAttribute('hidden'); return ( display === 'none' || hidden?.toLowerCase() === 'until-found' || (!display && hidden !== null) ); }; /** * Hide an element with an inline `display: none`, remembering the inline * `display` it had so {@link showElement} can put it back. * @internal */ export const hideElement = (el: Element): void => { const style = (el as HTMLElement).style; if (!style) return; if (style.display !== 'none') { displayBeforeHide.set(el, style.display); } style.display = 'none'; }; /** * Show an element: drop the `hidden` attribute and restore the inline * `display` from before {@link hideElement}. An explicit `display` wins. * Without one, an element that is not inline-hidden keeps its current value. * @internal */ export const showElement = (el: Element, display?: string): void => { el.removeAttribute('hidden'); const style = (el as HTMLElement).style; if (!style) return; const remembered = displayBeforeHide.get(el); displayBeforeHide.delete(el); if (display !== undefined) { style.display = display; } else if (style.display === 'none') { style.display = remembered ?? ''; } }; /** Handler signature for delegated events */ export type DelegatedHandler = (event: Event, target: Element) => void; /** * A single delegated registration plus the number of `delegate()` calls that * asked for it. The listener is attached once no matter how often the same * handler is delegated, but it is only detached when every registration has * been undelegated — otherwise one owner's teardown would silently kill an * identical delegation made by another. * @internal */ type DelegatedRegistration = { wrapper: EventListener; count: number }; /** * Delegated-listener registry shared by all wrapper instances and keyed by * the element itself, so a fresh `$`/`$$` wrapper can undelegate a listener * registered through an earlier one. * Outer map: element -> (key -> (handler -> registration)) * Key format: `${event}:${selector}` * @internal */ const delegatedHandlers = new WeakMap< Element, Map> >(); /** @internal */ export const addDelegatedListener = ( el: Element, event: string, selector: string, handler: DelegatedHandler ): void => { let elementHandlers = delegatedHandlers.get(el); if (!elementHandlers) { elementHandlers = new Map(); delegatedHandlers.set(el, elementHandlers); } const key = `${event}:${selector}`; let handlers = elementHandlers.get(key); if (!handlers) { handlers = new Map(); elementHandlers.set(key, handlers); } // Re-delegating the same handler for the same key keeps a single listener, // but each call is counted so it survives until it is undelegated as often // as it was delegated. const existing = handlers.get(handler); if (existing) { existing.count += 1; return; } const wrapper: EventListener = (e: Event) => { const eventTarget = e.target; // e.target can be a Text node, the document, or null (synthetic events). if (!eventTarget || (eventTarget as Node).nodeType !== 1) { return; } const target = (eventTarget as Element).closest(selector); if (target && el.contains(target)) { handler(e, target); } }; handlers.set(handler, { wrapper, count: 1 }); el.addEventListener(event, wrapper); }; /** @internal */ export const removeDelegatedListener = ( el: Element, event: string, selector: string, handler: DelegatedHandler ): void => { const elementHandlers = delegatedHandlers.get(el); if (!elementHandlers) return; const key = `${event}:${selector}`; const handlers = elementHandlers.get(key); if (!handlers) return; const registration = handlers.get(handler); if (!registration) return; // Detach only once the last owner has released its registration. registration.count -= 1; if (registration.count > 0) return; el.removeEventListener(event, registration.wrapper); handlers.delete(handler); // Clean up empty maps if (handlers.size === 0) { elementHandlers.delete(key); } if (elementHandlers.size === 0) { delegatedHandlers.delete(el); } }; export const toElementList = (input: Element | ElementList): ElementList => Array.isArray(input) ? input : [input]; export const applyAll = (elements: ElementList, action: (el: Element) => void) => { for (const el of elements) { action(el); } }; /** @internal */ export const isHTMLElement = (element: Element | null | undefined): element is HTMLElement => { if (!element) { return false; } const view = element.ownerDocument?.defaultView; const HTMLElementCtor = view?.HTMLElement ?? globalThis.HTMLElement; return typeof HTMLElementCtor === 'function' && element instanceof HTMLElementCtor; }; /** * Gets an element's inner size (content + padding, excluding border and margin). * * @internal */ export const getInnerSize = ( element: Element | null | undefined, dimension: 'width' | 'height' ): number => { if (!isHTMLElement(element)) { return 0; } return dimension === 'width' ? element.clientWidth : element.clientHeight; }; /** * Gets an element's outer size, optionally including margins. * * @internal */ export const getOuterSize = ( element: Element | null | undefined, dimension: 'width' | 'height', includeMargin: boolean ): number => { if (!isHTMLElement(element)) { return 0; } const size = dimension === 'width' ? element.offsetWidth : element.offsetHeight; if (!includeMargin) { return size; } const view = element.ownerDocument?.defaultView; if (!view || typeof view.getComputedStyle !== 'function') { return size; } const computedStyle = view.getComputedStyle(element); const startMargin = Number.parseFloat( computedStyle.getPropertyValue(dimension === 'width' ? 'margin-left' : 'margin-top') ); const endMargin = Number.parseFloat( computedStyle.getPropertyValue(dimension === 'width' ? 'margin-right' : 'margin-bottom') ); const safeStartMargin = Number.isNaN(startMargin) ? 0 : startMargin; const safeEndMargin = Number.isNaN(endMargin) ? 0 : endMargin; return size + safeStartMargin + safeEndMargin; };