UNPKG

htmx.org

Version:

high power tools for html

1,708 lines (1,604 loc) 166 kB
var htmx = (function() { 'use strict' // Public API const htmx = { // Tsc madness here, assigning the functions directly results in an invalid TypeScript output, but reassigning is fine /* Event processing */ /** @type {typeof onLoadHelper} */ onLoad: null, /** @type {typeof processNode} */ process: null, /** @type {typeof addEventListenerImpl} */ on: null, /** @type {typeof removeEventListenerImpl} */ off: null, /** @type {typeof triggerEvent} */ trigger: null, /** @type {typeof ajaxHelper} */ ajax: null, /* DOM querying helpers */ /** @type {typeof find} */ find: null, /** @type {typeof findAll} */ findAll: null, /** @type {typeof closest} */ closest: null, /** * Returns the input values that would resolve for a given element via the htmx value resolution mechanism * * @see https://htmx.org/api/#values * * @param {Element} elt the element to resolve values on * @param {HttpVerb} type the request type (e.g. **get** or **post**) non-GET's will include the enclosing form of the element. Defaults to **post** * @returns {Object} */ values: function(elt, type) { const inputValues = getInputValues(elt, type || 'post') return inputValues.values }, /* DOM manipulation helpers */ /** @type {typeof removeElement} */ remove: null, /** @type {typeof addClassToElement} */ addClass: null, /** @type {typeof removeClassFromElement} */ removeClass: null, /** @type {typeof toggleClassOnElement} */ toggleClass: null, /** @type {typeof takeClassForElement} */ takeClass: null, /** @type {typeof swap} */ swap: null, /* Extension entrypoints */ /** @type {typeof defineExtension} */ defineExtension: null, /** @type {typeof removeExtension} */ removeExtension: null, /* Debugging */ /** @type {typeof logAll} */ logAll: null, /** @type {typeof logNone} */ logNone: null, /* Debugging */ /** * The logger htmx uses to log with * * @see https://htmx.org/api/#logger */ logger: null, /** * A property holding the configuration htmx uses at runtime. * * Note that using a [meta tag](https://htmx.org/docs/#config) is the preferred mechanism for setting these properties. * * @see https://htmx.org/api/#config */ config: { /** * Whether to use history. * @type boolean * @default true */ historyEnabled: true, /** * The number of pages to keep in **localStorage** for history support. * @type number * @default 10 */ historyCacheSize: 10, /** * @type boolean * @default false */ refreshOnHistoryMiss: false, /** * The default swap style to use if **[hx-swap](https://htmx.org/attributes/hx-swap)** is omitted. * @type HtmxSwapStyle * @default 'innerHTML' */ defaultSwapStyle: 'innerHTML', /** * The default delay between receiving a response from the server and doing the swap. * @type number * @default 0 */ defaultSwapDelay: 0, /** * The default delay between completing the content swap and settling attributes. * @type number * @default 20 */ defaultSettleDelay: 20, /** * If true, htmx will inject a small amount of CSS into the page to make indicators invisible unless the **htmx-indicator** class is present. * @type boolean * @default true */ includeIndicatorStyles: true, /** * The class to place on indicators when a request is in flight. * @type string * @default 'htmx-indicator' */ indicatorClass: 'htmx-indicator', /** * The class to place on triggering elements when a request is in flight. * @type string * @default 'htmx-request' */ requestClass: 'htmx-request', /** * The class to temporarily place on elements that htmx has added to the DOM. * @type string * @default 'htmx-added' */ addedClass: 'htmx-added', /** * The class to place on target elements when htmx is in the settling phase. * @type string * @default 'htmx-settling' */ settlingClass: 'htmx-settling', /** * The class to place on target elements when htmx is in the swapping phase. * @type string * @default 'htmx-swapping' */ swappingClass: 'htmx-swapping', /** * Allows the use of eval-like functionality in htmx, to enable **hx-vars**, trigger conditions & script tag evaluation. Can be set to **false** for CSP compatibility. * @type boolean * @default true */ allowEval: true, /** * If set to false, disables the interpretation of script tags. * @type boolean * @default true */ allowScriptTags: true, /** * If set, the nonce will be added to inline scripts. * @type string * @default '' */ inlineScriptNonce: '', /** * If set, the nonce will be added to inline styles. * @type string * @default '' */ inlineStyleNonce: '', /** * The attributes to settle during the settling phase. * @type string[] * @default ['class', 'style', 'width', 'height'] */ attributesToSettle: ['class', 'style', 'width', 'height'], /** * Allow cross-site Access-Control requests using credentials such as cookies, authorization headers or TLS client certificates. * @type boolean * @default false */ withCredentials: false, /** * @type number * @default 0 */ timeout: 0, /** * The default implementation of **getWebSocketReconnectDelay** for reconnecting after unexpected connection loss by the event code **Abnormal Closure**, **Service Restart** or **Try Again Later**. * @type {'full-jitter' | ((retryCount:number) => number)} * @default "full-jitter" */ wsReconnectDelay: 'full-jitter', /** * The type of binary data being received over the WebSocket connection * @type BinaryType * @default 'blob' */ wsBinaryType: 'blob', /** * @type string * @default '[hx-disable], [data-hx-disable]' */ disableSelector: '[hx-disable], [data-hx-disable]', /** * @type {'auto' | 'instant' | 'smooth'} * @default 'instant' */ scrollBehavior: 'instant', /** * If the focused element should be scrolled into view. * @type boolean * @default false */ defaultFocusScroll: false, /** * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser * @type boolean * @default false */ getCacheBusterParam: false, /** * If set to true, htmx will use the View Transition API when swapping in new content. * @type boolean * @default false */ globalViewTransitions: false, /** * htmx will format requests with these methods by encoding their parameters in the URL, not the request body * @type {(HttpVerb)[]} * @default ['get', 'delete'] */ methodsThatUseUrlParams: ['get', 'delete'], /** * If set to true, disables htmx-based requests to non-origin hosts. * @type boolean * @default false */ selfRequestsOnly: true, /** * If set to true htmx will not update the title of the document when a title tag is found in new content * @type boolean * @default false */ ignoreTitle: false, /** * Whether the target of a boosted element is scrolled into the viewport. * @type boolean * @default true */ scrollIntoViewOnBoost: true, /** * The cache to store evaluated trigger specifications into. * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) * @type {Object|null} * @default null */ triggerSpecsCache: null, /** @type boolean */ disableInheritance: false, /** @type HtmxResponseHandlingConfig[] */ responseHandling: [ { code: '204', swap: false }, { code: '[23]..', swap: true }, { code: '[45]..', swap: false, error: true } ], /** * Whether to process OOB swaps on elements that are nested within the main response element. * @type boolean * @default true */ allowNestedOobSwaps: true }, /** @type {typeof parseInterval} */ parseInterval: null, /** @type {typeof internalEval} */ _: null, version: '2.0.4' } // Tsc madness part 2 htmx.onLoad = onLoadHelper htmx.process = processNode htmx.on = addEventListenerImpl htmx.off = removeEventListenerImpl htmx.trigger = triggerEvent htmx.ajax = ajaxHelper htmx.find = find htmx.findAll = findAll htmx.closest = closest htmx.remove = removeElement htmx.addClass = addClassToElement htmx.removeClass = removeClassFromElement htmx.toggleClass = toggleClassOnElement htmx.takeClass = takeClassForElement htmx.swap = swap htmx.defineExtension = defineExtension htmx.removeExtension = removeExtension htmx.logAll = logAll htmx.logNone = logNone htmx.parseInterval = parseInterval htmx._ = internalEval const internalAPI = { addTriggerHandler, bodyContains, canAccessLocalStorage, findThisElement, filterValues, swap, hasAttribute, getAttributeValue, getClosestAttributeValue, getClosestMatch, getExpressionVars, getHeaders, getInputValues, getInternalData, getSwapSpecification, getTriggerSpecs, getTarget, makeFragment, mergeObjects, makeSettleInfo, oobSwap, querySelectorExt, settleImmediately, shouldCancel, triggerEvent, triggerErrorEvent, withExtensions } const VERBS = ['get', 'post', 'put', 'delete', 'patch'] const VERB_SELECTOR = VERBS.map(function(verb) { return '[hx-' + verb + '], [data-hx-' + verb + ']' }).join(', ') //= =================================================================== // Utilities //= =================================================================== /** * Parses an interval string consistent with the way htmx does. Useful for plugins that have timing-related attributes. * * Caution: Accepts an int followed by either **s** or **ms**. All other values use **parseFloat** * * @see https://htmx.org/api/#parseInterval * * @param {string} str timing string * @returns {number|undefined} */ function parseInterval(str) { if (str == undefined) { return undefined } let interval = NaN if (str.slice(-2) == 'ms') { interval = parseFloat(str.slice(0, -2)) } else if (str.slice(-1) == 's') { interval = parseFloat(str.slice(0, -1)) * 1000 } else if (str.slice(-1) == 'm') { interval = parseFloat(str.slice(0, -1)) * 1000 * 60 } else { interval = parseFloat(str) } return isNaN(interval) ? undefined : interval } /** * @param {Node} elt * @param {string} name * @returns {(string | null)} */ function getRawAttribute(elt, name) { return elt instanceof Element && elt.getAttribute(name) } /** * @param {Element} elt * @param {string} qualifiedName * @returns {boolean} */ // resolve with both hx and data-hx prefixes function hasAttribute(elt, qualifiedName) { return !!elt.hasAttribute && (elt.hasAttribute(qualifiedName) || elt.hasAttribute('data-' + qualifiedName)) } /** * * @param {Node} elt * @param {string} qualifiedName * @returns {(string | null)} */ function getAttributeValue(elt, qualifiedName) { return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, 'data-' + qualifiedName) } /** * @param {Node} elt * @returns {Node | null} */ function parentElt(elt) { const parent = elt.parentElement if (!parent && elt.parentNode instanceof ShadowRoot) return elt.parentNode return parent } /** * @returns {Document} */ function getDocument() { return document } /** * @param {Node} elt * @param {boolean} global * @returns {Node|Document} */ function getRootNode(elt, global) { return elt.getRootNode ? elt.getRootNode({ composed: global }) : getDocument() } /** * @param {Node} elt * @param {(e:Node) => boolean} condition * @returns {Node | null} */ function getClosestMatch(elt, condition) { while (elt && !condition(elt)) { elt = parentElt(elt) } return elt || null } /** * @param {Element} initialElement * @param {Element} ancestor * @param {string} attributeName * @returns {string|null} */ function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName) { const attributeValue = getAttributeValue(ancestor, attributeName) const disinherit = getAttributeValue(ancestor, 'hx-disinherit') var inherit = getAttributeValue(ancestor, 'hx-inherit') if (initialElement !== ancestor) { if (htmx.config.disableInheritance) { if (inherit && (inherit === '*' || inherit.split(' ').indexOf(attributeName) >= 0)) { return attributeValue } else { return null } } if (disinherit && (disinherit === '*' || disinherit.split(' ').indexOf(attributeName) >= 0)) { return 'unset' } } return attributeValue } /** * @param {Element} elt * @param {string} attributeName * @returns {string | null} */ function getClosestAttributeValue(elt, attributeName) { let closestAttr = null getClosestMatch(elt, function(e) { return !!(closestAttr = getAttributeValueWithDisinheritance(elt, asElement(e), attributeName)) }) if (closestAttr !== 'unset') { return closestAttr } } /** * @param {Node} elt * @param {string} selector * @returns {boolean} */ function matches(elt, selector) { // @ts-ignore: non-standard properties for browser compatibility // noinspection JSUnresolvedVariable const matchesFunction = elt instanceof Element && (elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector) return !!matchesFunction && matchesFunction.call(elt, selector) } /** * @param {string} str * @returns {string} */ function getStartTag(str) { const tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i const match = tagMatcher.exec(str) if (match) { return match[1].toLowerCase() } else { return '' } } /** * @param {string} resp * @returns {Document} */ function parseHTML(resp) { const parser = new DOMParser() return parser.parseFromString(resp, 'text/html') } /** * @param {DocumentFragment} fragment * @param {Node} elt */ function takeChildrenFor(fragment, elt) { while (elt.childNodes.length > 0) { fragment.append(elt.childNodes[0]) } } /** * @param {HTMLScriptElement} script * @returns {HTMLScriptElement} */ function duplicateScript(script) { const newScript = getDocument().createElement('script') forEach(script.attributes, function(attr) { newScript.setAttribute(attr.name, attr.value) }) newScript.textContent = script.textContent newScript.async = false if (htmx.config.inlineScriptNonce) { newScript.nonce = htmx.config.inlineScriptNonce } return newScript } /** * @param {HTMLScriptElement} script * @returns {boolean} */ function isJavaScriptScriptNode(script) { return script.matches('script') && (script.type === 'text/javascript' || script.type === 'module' || script.type === '') } /** * we have to make new copies of script tags that we are going to insert because * SOME browsers (not saying who, but it involves an element and an animal) don't * execute scripts created in <template> tags when they are inserted into the DOM * and all the others do lmao * @param {DocumentFragment} fragment */ function normalizeScriptTags(fragment) { Array.from(fragment.querySelectorAll('script')).forEach(/** @param {HTMLScriptElement} script */ (script) => { if (isJavaScriptScriptNode(script)) { const newScript = duplicateScript(script) const parent = script.parentNode try { parent.insertBefore(newScript, script) } catch (e) { logError(e) } finally { script.remove() } } }) } /** * @typedef {DocumentFragment & {title?: string}} DocumentFragmentWithTitle * @description a document fragment representing the response HTML, including * a `title` property for any title information found */ /** * @param {string} response HTML * @returns {DocumentFragmentWithTitle} */ function makeFragment(response) { // strip head tag to determine shape of response we are dealing with const responseWithNoHead = response.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i, '') const startTag = getStartTag(responseWithNoHead) /** @type DocumentFragmentWithTitle */ let fragment if (startTag === 'html') { // if it is a full document, parse it and return the body fragment = /** @type DocumentFragmentWithTitle */ (new DocumentFragment()) const doc = parseHTML(response) takeChildrenFor(fragment, doc.body) fragment.title = doc.title } else if (startTag === 'body') { // parse body w/o wrapping in template fragment = /** @type DocumentFragmentWithTitle */ (new DocumentFragment()) const doc = parseHTML(responseWithNoHead) takeChildrenFor(fragment, doc.body) fragment.title = doc.title } else { // otherwise we have non-body partial HTML content, so wrap it in a template to maximize parsing flexibility const doc = parseHTML('<body><template class="internal-htmx-wrapper">' + responseWithNoHead + '</template></body>') fragment = /** @type DocumentFragmentWithTitle */ (doc.querySelector('template').content) // extract title into fragment for later processing fragment.title = doc.title // for legacy reasons we support a title tag at the root level of non-body responses, so we need to handle it var titleElement = fragment.querySelector('title') if (titleElement && titleElement.parentNode === fragment) { titleElement.remove() fragment.title = titleElement.innerText } } if (fragment) { if (htmx.config.allowScriptTags) { normalizeScriptTags(fragment) } else { // remove all script tags if scripts are disabled fragment.querySelectorAll('script').forEach((script) => script.remove()) } } return fragment } /** * @param {Function} func */ function maybeCall(func) { if (func) { func() } } /** * @param {any} o * @param {string} type * @returns */ function isType(o, type) { return Object.prototype.toString.call(o) === '[object ' + type + ']' } /** * @param {*} o * @returns {o is Function} */ function isFunction(o) { return typeof o === 'function' } /** * @param {*} o * @returns {o is Object} */ function isRawObject(o) { return isType(o, 'Object') } /** * @typedef {Object} OnHandler * @property {(keyof HTMLElementEventMap)|string} event * @property {EventListener} listener */ /** * @typedef {Object} ListenerInfo * @property {string} trigger * @property {EventListener} listener * @property {EventTarget} on */ /** * @typedef {Object} HtmxNodeInternalData * Element data * @property {number} [initHash] * @property {boolean} [boosted] * @property {OnHandler[]} [onHandlers] * @property {number} [timeout] * @property {ListenerInfo[]} [listenerInfos] * @property {boolean} [cancelled] * @property {boolean} [triggeredOnce] * @property {number} [delayed] * @property {number|null} [throttle] * @property {WeakMap<HtmxTriggerSpecification,WeakMap<EventTarget,string>>} [lastValue] * @property {boolean} [loaded] * @property {string} [path] * @property {string} [verb] * @property {boolean} [polling] * @property {HTMLButtonElement|HTMLInputElement|null} [lastButtonClicked] * @property {number} [requestCount] * @property {XMLHttpRequest} [xhr] * @property {(() => void)[]} [queuedRequests] * @property {boolean} [abortable] * @property {boolean} [firstInitCompleted] * * Event data * @property {HtmxTriggerSpecification} [triggerSpec] * @property {EventTarget[]} [handledFor] */ /** * getInternalData retrieves "private" data stored by htmx within an element * @param {EventTarget|Event} elt * @returns {HtmxNodeInternalData} */ function getInternalData(elt) { const dataProp = 'htmx-internal-data' let data = elt[dataProp] if (!data) { data = elt[dataProp] = {} } return data } /** * toArray converts an ArrayLike object into a real array. * @template T * @param {ArrayLike<T>} arr * @returns {T[]} */ function toArray(arr) { const returnArr = [] if (arr) { for (let i = 0; i < arr.length; i++) { returnArr.push(arr[i]) } } return returnArr } /** * @template T * @param {T[]|NamedNodeMap|HTMLCollection|HTMLFormControlsCollection|ArrayLike<T>} arr * @param {(T) => void} func */ function forEach(arr, func) { if (arr) { for (let i = 0; i < arr.length; i++) { func(arr[i]) } } } /** * @param {Element} el * @returns {boolean} */ function isScrolledIntoView(el) { const rect = el.getBoundingClientRect() const elemTop = rect.top const elemBottom = rect.bottom return elemTop < window.innerHeight && elemBottom >= 0 } /** * Checks whether the element is in the document (includes shadow roots). * This function this is a slight misnomer; it will return true even for elements in the head. * * @param {Node} elt * @returns {boolean} */ function bodyContains(elt) { return elt.getRootNode({ composed: true }) === document } /** * @param {string} trigger * @returns {string[]} */ function splitOnWhitespace(trigger) { return trigger.trim().split(/\s+/) } /** * mergeObjects takes all the keys from * obj2 and duplicates them into obj1 * @template T1 * @template T2 * @param {T1} obj1 * @param {T2} obj2 * @returns {T1 & T2} */ function mergeObjects(obj1, obj2) { for (const key in obj2) { if (obj2.hasOwnProperty(key)) { // @ts-ignore tsc doesn't seem to properly handle types merging obj1[key] = obj2[key] } } // @ts-ignore tsc doesn't seem to properly handle types merging return obj1 } /** * @param {string} jString * @returns {any|null} */ function parseJSON(jString) { try { return JSON.parse(jString) } catch (error) { logError(error) return null } } /** * @returns {boolean} */ function canAccessLocalStorage() { const test = 'htmx:localStorageTest' try { localStorage.setItem(test, test) localStorage.removeItem(test) return true } catch (e) { return false } } /** * @param {string} path * @returns {string} */ function normalizePath(path) { try { const url = new URL(path) if (url) { path = url.pathname + url.search } // remove trailing slash, unless index page if (!(/^\/$/.test(path))) { path = path.replace(/\/+$/, '') } return path } catch (e) { // be kind to IE11, which doesn't support URL() return path } } //= ========================================================================================= // public API //= ========================================================================================= /** * @param {string} str * @returns {any} */ function internalEval(str) { return maybeEval(getDocument().body, function() { return eval(str) }) } /** * Adds a callback for the **htmx:load** event. This can be used to process new content, for example initializing the content with a javascript library * * @see https://htmx.org/api/#onLoad * * @param {(elt: Node) => void} callback the callback to call on newly loaded content * @returns {EventListener} */ function onLoadHelper(callback) { const value = htmx.on('htmx:load', /** @param {CustomEvent} evt */ function(evt) { callback(evt.detail.elt) }) return value } /** * Log all htmx events, useful for debugging. * * @see https://htmx.org/api/#logAll */ function logAll() { htmx.logger = function(elt, event, data) { if (console) { console.log(event, elt, data) } } } function logNone() { htmx.logger = null } /** * Finds an element matching the selector * * @see https://htmx.org/api/#find * * @param {ParentNode|string} eltOrSelector the root element to find the matching element in, inclusive | the selector to match * @param {string} [selector] the selector to match * @returns {Element|null} */ function find(eltOrSelector, selector) { if (typeof eltOrSelector !== 'string') { return eltOrSelector.querySelector(selector) } else { return find(getDocument(), eltOrSelector) } } /** * Finds all elements matching the selector * * @see https://htmx.org/api/#findAll * * @param {ParentNode|string} eltOrSelector the root element to find the matching elements in, inclusive | the selector to match * @param {string} [selector] the selector to match * @returns {NodeListOf<Element>} */ function findAll(eltOrSelector, selector) { if (typeof eltOrSelector !== 'string') { return eltOrSelector.querySelectorAll(selector) } else { return findAll(getDocument(), eltOrSelector) } } /** * @returns Window */ function getWindow() { return window } /** * Removes an element from the DOM * * @see https://htmx.org/api/#remove * * @param {Node} elt * @param {number} [delay] */ function removeElement(elt, delay) { elt = resolveTarget(elt) if (delay) { getWindow().setTimeout(function() { removeElement(elt) elt = null }, delay) } else { parentElt(elt).removeChild(elt) } } /** * @param {any} elt * @return {Element|null} */ function asElement(elt) { return elt instanceof Element ? elt : null } /** * @param {any} elt * @return {HTMLElement|null} */ function asHtmlElement(elt) { return elt instanceof HTMLElement ? elt : null } /** * @param {any} value * @return {string|null} */ function asString(value) { return typeof value === 'string' ? value : null } /** * @param {EventTarget} elt * @return {ParentNode|null} */ function asParentNode(elt) { return elt instanceof Element || elt instanceof Document || elt instanceof DocumentFragment ? elt : null } /** * This method adds a class to the given element. * * @see https://htmx.org/api/#addClass * * @param {Element|string} elt the element to add the class to * @param {string} clazz the class to add * @param {number} [delay] the delay (in milliseconds) before class is added */ function addClassToElement(elt, clazz, delay) { elt = asElement(resolveTarget(elt)) if (!elt) { return } if (delay) { getWindow().setTimeout(function() { addClassToElement(elt, clazz) elt = null }, delay) } else { elt.classList && elt.classList.add(clazz) } } /** * Removes a class from the given element * * @see https://htmx.org/api/#removeClass * * @param {Node|string} node element to remove the class from * @param {string} clazz the class to remove * @param {number} [delay] the delay (in milliseconds before class is removed) */ function removeClassFromElement(node, clazz, delay) { let elt = asElement(resolveTarget(node)) if (!elt) { return } if (delay) { getWindow().setTimeout(function() { removeClassFromElement(elt, clazz) elt = null }, delay) } else { if (elt.classList) { elt.classList.remove(clazz) // if there are no classes left, remove the class attribute if (elt.classList.length === 0) { elt.removeAttribute('class') } } } } /** * Toggles the given class on an element * * @see https://htmx.org/api/#toggleClass * * @param {Element|string} elt the element to toggle the class on * @param {string} clazz the class to toggle */ function toggleClassOnElement(elt, clazz) { elt = resolveTarget(elt) elt.classList.toggle(clazz) } /** * Takes the given class from its siblings, so that among its siblings, only the given element will have the class. * * @see https://htmx.org/api/#takeClass * * @param {Node|string} elt the element that will take the class * @param {string} clazz the class to take */ function takeClassForElement(elt, clazz) { elt = resolveTarget(elt) forEach(elt.parentElement.children, function(child) { removeClassFromElement(child, clazz) }) addClassToElement(asElement(elt), clazz) } /** * Finds the closest matching element in the given elements parentage, inclusive of the element * * @see https://htmx.org/api/#closest * * @param {Element|string} elt the element to find the selector from * @param {string} selector the selector to find * @returns {Element|null} */ function closest(elt, selector) { elt = asElement(resolveTarget(elt)) if (elt && elt.closest) { return elt.closest(selector) } else { // TODO remove when IE goes away do { if (elt == null || matches(elt, selector)) { return elt } } while (elt = elt && asElement(parentElt(elt))) return null } } /** * @param {string} str * @param {string} prefix * @returns {boolean} */ function startsWith(str, prefix) { return str.substring(0, prefix.length) === prefix } /** * @param {string} str * @param {string} suffix * @returns {boolean} */ function endsWith(str, suffix) { return str.substring(str.length - suffix.length) === suffix } /** * @param {string} selector * @returns {string} */ function normalizeSelector(selector) { const trimmedSelector = selector.trim() if (startsWith(trimmedSelector, '<') && endsWith(trimmedSelector, '/>')) { return trimmedSelector.substring(1, trimmedSelector.length - 2) } else { return trimmedSelector } } /** * @param {Node|Element|Document|string} elt * @param {string} selector * @param {boolean=} global * @returns {(Node|Window)[]} */ function querySelectorAllExt(elt, selector, global) { if (selector.indexOf('global ') === 0) { return querySelectorAllExt(elt, selector.slice(7), true) } elt = resolveTarget(elt) const parts = [] { let chevronsCount = 0 let offset = 0 for (let i = 0; i < selector.length; i++) { const char = selector[i] if (char === ',' && chevronsCount === 0) { parts.push(selector.substring(offset, i)) offset = i + 1 continue } if (char === '<') { chevronsCount++ } else if (char === '/' && i < selector.length - 1 && selector[i + 1] === '>') { chevronsCount-- } } if (offset < selector.length) { parts.push(selector.substring(offset)) } } const result = [] const unprocessedParts = [] while (parts.length > 0) { const selector = normalizeSelector(parts.shift()) let item if (selector.indexOf('closest ') === 0) { item = closest(asElement(elt), normalizeSelector(selector.substr(8))) } else if (selector.indexOf('find ') === 0) { item = find(asParentNode(elt), normalizeSelector(selector.substr(5))) } else if (selector === 'next' || selector === 'nextElementSibling') { item = asElement(elt).nextElementSibling } else if (selector.indexOf('next ') === 0) { item = scanForwardQuery(elt, normalizeSelector(selector.substr(5)), !!global) } else if (selector === 'previous' || selector === 'previousElementSibling') { item = asElement(elt).previousElementSibling } else if (selector.indexOf('previous ') === 0) { item = scanBackwardsQuery(elt, normalizeSelector(selector.substr(9)), !!global) } else if (selector === 'document') { item = document } else if (selector === 'window') { item = window } else if (selector === 'body') { item = document.body } else if (selector === 'root') { item = getRootNode(elt, !!global) } else if (selector === 'host') { item = (/** @type ShadowRoot */(elt.getRootNode())).host } else { unprocessedParts.push(selector) } if (item) { result.push(item) } } if (unprocessedParts.length > 0) { const standardSelector = unprocessedParts.join(',') const rootNode = asParentNode(getRootNode(elt, !!global)) result.push(...toArray(rootNode.querySelectorAll(standardSelector))) } return result } /** * @param {Node} start * @param {string} match * @param {boolean} global * @returns {Element} */ var scanForwardQuery = function(start, match, global) { const results = asParentNode(getRootNode(start, global)).querySelectorAll(match) for (let i = 0; i < results.length; i++) { const elt = results[i] if (elt.compareDocumentPosition(start) === Node.DOCUMENT_POSITION_PRECEDING) { return elt } } } /** * @param {Node} start * @param {string} match * @param {boolean} global * @returns {Element} */ var scanBackwardsQuery = function(start, match, global) { const results = asParentNode(getRootNode(start, global)).querySelectorAll(match) for (let i = results.length - 1; i >= 0; i--) { const elt = results[i] if (elt.compareDocumentPosition(start) === Node.DOCUMENT_POSITION_FOLLOWING) { return elt } } } /** * @param {Node|string} eltOrSelector * @param {string=} selector * @returns {Node|Window} */ function querySelectorExt(eltOrSelector, selector) { if (typeof eltOrSelector !== 'string') { return querySelectorAllExt(eltOrSelector, selector)[0] } else { return querySelectorAllExt(getDocument().body, eltOrSelector)[0] } } /** * @template {EventTarget} T * @param {T|string} eltOrSelector * @param {T} [context] * @returns {Element|T|null} */ function resolveTarget(eltOrSelector, context) { if (typeof eltOrSelector === 'string') { return find(asParentNode(context) || document, eltOrSelector) } else { return eltOrSelector } } /** * @typedef {keyof HTMLElementEventMap|string} AnyEventName */ /** * @typedef {Object} EventArgs * @property {EventTarget} target * @property {AnyEventName} event * @property {EventListener} listener * @property {Object|boolean} options */ /** * @param {EventTarget|AnyEventName} arg1 * @param {AnyEventName|EventListener} arg2 * @param {EventListener|Object|boolean} [arg3] * @param {Object|boolean} [arg4] * @returns {EventArgs} */ function processEventArgs(arg1, arg2, arg3, arg4) { if (isFunction(arg2)) { return { target: getDocument().body, event: asString(arg1), listener: arg2, options: arg3 } } else { return { target: resolveTarget(arg1), event: asString(arg2), listener: arg3, options: arg4 } } } /** * Adds an event listener to an element * * @see https://htmx.org/api/#on * * @param {EventTarget|string} arg1 the element to add the listener to | the event name to add the listener for * @param {string|EventListener} arg2 the event name to add the listener for | the listener to add * @param {EventListener|Object|boolean} [arg3] the listener to add | options to add * @param {Object|boolean} [arg4] options to add * @returns {EventListener} */ function addEventListenerImpl(arg1, arg2, arg3, arg4) { ready(function() { const eventArgs = processEventArgs(arg1, arg2, arg3, arg4) eventArgs.target.addEventListener(eventArgs.event, eventArgs.listener, eventArgs.options) }) const b = isFunction(arg2) return b ? arg2 : arg3 } /** * Removes an event listener from an element * * @see https://htmx.org/api/#off * * @param {EventTarget|string} arg1 the element to remove the listener from | the event name to remove the listener from * @param {string|EventListener} arg2 the event name to remove the listener from | the listener to remove * @param {EventListener} [arg3] the listener to remove * @returns {EventListener} */ function removeEventListenerImpl(arg1, arg2, arg3) { ready(function() { const eventArgs = processEventArgs(arg1, arg2, arg3) eventArgs.target.removeEventListener(eventArgs.event, eventArgs.listener) }) return isFunction(arg2) ? arg2 : arg3 } //= =================================================================== // Node processing //= =================================================================== const DUMMY_ELT = getDocument().createElement('output') // dummy element for bad selectors /** * @param {Element} elt * @param {string} attrName * @returns {(Node|Window)[]} */ function findAttributeTargets(elt, attrName) { const attrTarget = getClosestAttributeValue(elt, attrName) if (attrTarget) { if (attrTarget === 'this') { return [findThisElement(elt, attrName)] } else { const result = querySelectorAllExt(elt, attrTarget) if (result.length === 0) { logError('The selector "' + attrTarget + '" on ' + attrName + ' returned no matches!') return [DUMMY_ELT] } else { return result } } } } /** * @param {Element} elt * @param {string} attribute * @returns {Element|null} */ function findThisElement(elt, attribute) { return asElement(getClosestMatch(elt, function(elt) { return getAttributeValue(asElement(elt), attribute) != null })) } /** * @param {Element} elt * @returns {Node|Window|null} */ function getTarget(elt) { const targetStr = getClosestAttributeValue(elt, 'hx-target') if (targetStr) { if (targetStr === 'this') { return findThisElement(elt, 'hx-target') } else { return querySelectorExt(elt, targetStr) } } else { const data = getInternalData(elt) if (data.boosted) { return getDocument().body } else { return elt } } } /** * @param {string} name * @returns {boolean} */ function shouldSettleAttribute(name) { const attributesToSettle = htmx.config.attributesToSettle for (let i = 0; i < attributesToSettle.length; i++) { if (name === attributesToSettle[i]) { return true } } return false } /** * @param {Element} mergeTo * @param {Element} mergeFrom */ function cloneAttributes(mergeTo, mergeFrom) { forEach(mergeTo.attributes, function(attr) { if (!mergeFrom.hasAttribute(attr.name) && shouldSettleAttribute(attr.name)) { mergeTo.removeAttribute(attr.name) } }) forEach(mergeFrom.attributes, function(attr) { if (shouldSettleAttribute(attr.name)) { mergeTo.setAttribute(attr.name, attr.value) } }) } /** * @param {HtmxSwapStyle} swapStyle * @param {Element} target * @returns {boolean} */ function isInlineSwap(swapStyle, target) { const extensions = getExtensions(target) for (let i = 0; i < extensions.length; i++) { const extension = extensions[i] try { if (extension.isInlineSwap(swapStyle)) { return true } } catch (e) { logError(e) } } return swapStyle === 'outerHTML' } /** * @param {string} oobValue * @param {Element} oobElement * @param {HtmxSettleInfo} settleInfo * @param {Node|Document} [rootNode] * @returns */ function oobSwap(oobValue, oobElement, settleInfo, rootNode) { rootNode = rootNode || getDocument() let selector = '#' + getRawAttribute(oobElement, 'id') /** @type HtmxSwapStyle */ let swapStyle = 'outerHTML' if (oobValue === 'true') { // do nothing } else if (oobValue.indexOf(':') > 0) { swapStyle = oobValue.substring(0, oobValue.indexOf(':')) selector = oobValue.substring(oobValue.indexOf(':') + 1) } else { swapStyle = oobValue } oobElement.removeAttribute('hx-swap-oob') oobElement.removeAttribute('data-hx-swap-oob') const targets = querySelectorAllExt(rootNode, selector, false) if (targets) { forEach( targets, function(target) { let fragment const oobElementClone = oobElement.cloneNode(true) fragment = getDocument().createDocumentFragment() fragment.appendChild(oobElementClone) if (!isInlineSwap(swapStyle, target)) { fragment = asParentNode(oobElementClone) // if this is not an inline swap, we use the content of the node, not the node itself } const beforeSwapDetails = { shouldSwap: true, target, fragment } if (!triggerEvent(target, 'htmx:oobBeforeSwap', beforeSwapDetails)) return target = beforeSwapDetails.target // allow re-targeting if (beforeSwapDetails.shouldSwap) { handlePreservedElements(fragment) swapWithStyle(swapStyle, target, target, fragment, settleInfo) restorePreservedElements() } forEach(settleInfo.elts, function(elt) { triggerEvent(elt, 'htmx:oobAfterSwap', beforeSwapDetails) }) } ) oobElement.parentNode.removeChild(oobElement) } else { oobElement.parentNode.removeChild(oobElement) triggerErrorEvent(getDocument().body, 'htmx:oobErrorNoTarget', { content: oobElement }) } return oobValue } function restorePreservedElements() { const pantry = find('#--htmx-preserve-pantry--') if (pantry) { for (const preservedElt of [...pantry.children]) { const existingElement = find('#' + preservedElt.id) // @ts-ignore - use proposed moveBefore feature existingElement.parentNode.moveBefore(preservedElt, existingElement) existingElement.remove() } pantry.remove() } } /** * @param {DocumentFragment|ParentNode} fragment */ function handlePreservedElements(fragment) { forEach(findAll(fragment, '[hx-preserve], [data-hx-preserve]'), function(preservedElt) { const id = getAttributeValue(preservedElt, 'id') const existingElement = getDocument().getElementById(id) if (existingElement != null) { if (preservedElt.moveBefore) { // if the moveBefore API exists, use it // get or create a storage spot for stuff let pantry = find('#--htmx-preserve-pantry--') if (pantry == null) { getDocument().body.insertAdjacentHTML('afterend', "<div id='--htmx-preserve-pantry--'></div>") pantry = find('#--htmx-preserve-pantry--') } // @ts-ignore - use proposed moveBefore feature pantry.moveBefore(existingElement, null) } else { preservedElt.parentNode.replaceChild(existingElement, preservedElt) } } }) } /** * @param {Node} parentNode * @param {ParentNode} fragment * @param {HtmxSettleInfo} settleInfo */ function handleAttributes(parentNode, fragment, settleInfo) { forEach(fragment.querySelectorAll('[id]'), function(newNode) { const id = getRawAttribute(newNode, 'id') if (id && id.length > 0) { const normalizedId = id.replace("'", "\\'") const normalizedTag = newNode.tagName.replace(':', '\\:') const parentElt = asParentNode(parentNode) const oldNode = parentElt && parentElt.querySelector(normalizedTag + "[id='" + normalizedId + "']") if (oldNode && oldNode !== parentElt) { const newAttributes = newNode.cloneNode() cloneAttributes(newNode, oldNode) settleInfo.tasks.push(function() { cloneAttributes(newNode, newAttributes) }) } } }) } /** * @param {Node} child * @returns {HtmxSettleTask} */ function makeAjaxLoadTask(child) { return function() { removeClassFromElement(child, htmx.config.addedClass) processNode(asElement(child)) processFocus(asParentNode(child)) triggerEvent(child, 'htmx:load') } } /** * @param {ParentNode} child */ function processFocus(child) { const autofocus = '[autofocus]' const autoFocusedElt = asHtmlElement(matches(child, autofocus) ? child : child.querySelector(autofocus)) if (autoFocusedElt != null) { autoFocusedElt.focus() } } /** * @param {Node} parentNode * @param {Node} insertBefore * @param {ParentNode} fragment * @param {HtmxSettleInfo} settleInfo */ function insertNodesBefore(parentNode, insertBefore, fragment, settleInfo) { handleAttributes(parentNode, fragment, settleInfo) while (fragment.childNodes.length > 0) { const child = fragment.firstChild addClassToElement(asElement(child), htmx.config.addedClass) parentNode.insertBefore(child, insertBefore) if (child.nodeType !== Node.TEXT_NODE && child.nodeType !== Node.COMMENT_NODE) { settleInfo.tasks.push(makeAjaxLoadTask(child)) } } } /** * based on https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0, * derived from Java's string hashcode implementation * @param {string} string * @param {number} hash * @returns {number} */ function stringHash(string, hash) { let char = 0 while (char < string.length) { hash = (hash << 5) - hash + string.charCodeAt(char++) | 0 // bitwise or ensures we have a 32-bit int } return hash } /** * @param {Element} elt * @returns {number} */ function attributeHash(elt) { let hash = 0 // IE fix if (elt.attributes) { for (let i = 0; i < elt.attributes.length; i++) { const attribute = elt.attributes[i] if (attribute.value) { // only include attributes w/ actual values (empty is same as non-existent) hash = stringHash(attribute.name, hash) hash = stringHash(attribute.value, hash) } } } return hash } /** * @param {EventTarget} elt */ function deInitOnHandlers(elt) { const internalData = getInternalData(elt) if (internalData.onHandlers) { for (let i = 0; i < internalData.onHandlers.length; i++) { const handlerInfo = internalData.onHandlers[i] removeEventListenerImpl(elt, handlerInfo.event, handlerInfo.listener) } delete internalData.onHandlers } } /** * @param {Node} element */ function deInitNode(element) { const internalData = getInternalData(element) if (internalData.timeout) { clearTimeout(internalData.timeout) } if (internalData.listenerInfos) { forEach(internalData.listenerInfos, function(info) { if (info.on) { removeEventListenerImpl(info.on, info.trigger, info.listener) } }) } deInitOnHandlers(element) forEach(Object.keys(internalData), function(key) { if (key !== 'firstInitCompleted') delete internalData[key] }) } /** * @param {Node} element */ function cleanUpElement(element) { triggerEvent(element, 'htmx:beforeCleanupElement') deInitNode(element) // @ts-ignore IE11 code // noinspection JSUnresolvedReference if (element.children) { // IE // @ts-ignore forEach(element.children, function(child) { cleanUpElement(child) }) } } /** * @param {Node} target * @param {ParentNode} fragment * @param {HtmxSettleInfo} settleInfo */ function swapOuterHTML(target, fragment, settleInfo) { if (target instanceof Element && target.tagName === 'BODY') { // special case the body to innerHTML because DocumentFragments can't contain a body elt unfortunately return swapInnerHTML(target, fragment, settleInfo) } /** @type {Node} */ let newElt const eltBeforeNewContent = target.previousSibling const parentNode = parentElt(target) if (!parentNode) { // when parent node disappears, we can't do anything return } insertNodesBefore(parentNode, target, fragment, settleInfo) if (eltBeforeNewContent == null) { newElt = parentNode.firstChild