htmx.org
Version:
high power tools for html
1,708 lines (1,604 loc) • 166 kB
JavaScript
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