mirror of
https://github.com/bigskysoftware/htmx.git
synced 2026-03-11 00:19:09 +00:00
2253 lines
90 KiB
JavaScript
2253 lines
90 KiB
JavaScript
// noinspection ES6ConvertVarToLetConst
|
|
var htmx = (() => {
|
|
|
|
class ReqQ {
|
|
#c = null
|
|
#q = []
|
|
|
|
issue(ctx, queueStrategy) {
|
|
if (!this.#c) {
|
|
this.#c = ctx
|
|
return true
|
|
} else {
|
|
// Update ctx.status properly for replaced request contexts
|
|
if (queueStrategy === "replace") {
|
|
this.#q.map(value => value.status = "dropped");
|
|
this.#q = []
|
|
if (this.#c) {
|
|
this.#c.abort();
|
|
}
|
|
return true
|
|
} else if (queueStrategy === "queue all") {
|
|
this.#q.push(ctx)
|
|
ctx.status = "queued";
|
|
} else if (queueStrategy === "drop") {
|
|
// ignore the request
|
|
ctx.status = "dropped";
|
|
} else if (queueStrategy === "queue last") {
|
|
this.#q.map(value => value.status = "dropped");
|
|
this.#q = [ctx]
|
|
ctx.status = "queued";
|
|
} else if (this.#q.length === 0) {
|
|
// default queue first
|
|
this.#q.push(ctx)
|
|
ctx.status = "queued";
|
|
} else {
|
|
ctx.status = "dropped";
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
finish() {
|
|
this.#c = null
|
|
}
|
|
|
|
next() {
|
|
return this.#q.shift()
|
|
}
|
|
|
|
abort() {
|
|
this.#c?.abort?.()
|
|
}
|
|
|
|
more() {
|
|
return this.#q?.length
|
|
}
|
|
}
|
|
|
|
class Htmx {
|
|
|
|
#extMethods = new Map();
|
|
#approvedExt = '';
|
|
#registeredExt = new Set();
|
|
#internalAPI;
|
|
#actionSelector
|
|
#boostSelector = "a,form";
|
|
#verbs = ["get", "post", "put", "patch", "delete"];
|
|
#hxOnQuery
|
|
#transitionQueue
|
|
#processingTransition
|
|
|
|
constructor() {
|
|
this.#initHtmxConfig();
|
|
this.#initRequestIndicatorCss();
|
|
this.#actionSelector = `[${this.#prefix("hx-action")}],[${this.#prefix("hx-get")}],[${this.#prefix("hx-post")}],[${this.#prefix("hx-put")}],[${this.#prefix("hx-patch")}],[${this.#prefix("hx-delete")}]`;
|
|
this.#hxOnQuery = new XPathEvaluator().createExpression(`.//*[@*[ starts-with(name(), "${this.#prefix("hx-on")}")]]`);
|
|
this.#internalAPI = {
|
|
attributeValue: this.#attributeValue.bind(this),
|
|
parseTriggerSpecs: this.#parseTriggerSpecs.bind(this),
|
|
determineMethodAndAction: this.#determineMethodAndAction.bind(this),
|
|
createRequestContext: this.#createRequestContext.bind(this),
|
|
collectFormData: this.#collectFormData.bind(this),
|
|
handleHxVals: this.#handleHxVals.bind(this)
|
|
};
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
this.#initHistoryHandling();
|
|
this.process(document.body)
|
|
})
|
|
}
|
|
|
|
#initHtmxConfig() {
|
|
this.config = {
|
|
version: '4.0.0-alpha2',
|
|
logAll: false,
|
|
prefix: "",
|
|
transitions: true,
|
|
history: true,
|
|
historyReload: false,
|
|
mode: 'same-origin',
|
|
defaultSwap: "innerHTML",
|
|
indicatorClass: "htmx-indicator",
|
|
requestClass: "htmx-request",
|
|
includeIndicatorCSS: true,
|
|
defaultTimeout: 60000, /* 60 second default timeout */
|
|
extensions: '',
|
|
streams: {
|
|
mode: 'once',
|
|
maxRetries: Infinity,
|
|
initialDelay: 500,
|
|
maxDelay: 30000,
|
|
pauseHidden: false
|
|
},
|
|
morphIgnore: ["data-htmx-powered"],
|
|
noSwap: [204, 304],
|
|
implicitInheritance: false
|
|
}
|
|
let metaConfig = document.querySelector('meta[name="htmx:config"]');
|
|
if (metaConfig) {
|
|
let overrides = JSON.parse(metaConfig.content);
|
|
// Deep merge nested config objects
|
|
for (let key in overrides) {
|
|
let val = overrides[key];
|
|
if (val && typeof val === 'object' && !Array.isArray(val) && this.config[key]) {
|
|
Object.assign(this.config[key], val);
|
|
} else {
|
|
this.config[key] = val;
|
|
}
|
|
}
|
|
}
|
|
this.#approvedExt = this.config.extensions;
|
|
}
|
|
|
|
#initRequestIndicatorCss() {
|
|
if (this.config.includeIndicatorCSS !== false) {
|
|
let nonceAttribute = "";
|
|
if (this.config.inlineStyleNonce) {
|
|
nonceAttribute = ` nonce="${this.config.inlineStyleNonce}"`;
|
|
}
|
|
let indicator = this.config.indicatorClass
|
|
let request = this.config.requestClass
|
|
document.head.insertAdjacentHTML('beforeend', `<style${nonceAttribute}>` +
|
|
`.${indicator}{opacity:0;visibility: hidden} ` +
|
|
`.${request} .${indicator}, .${request}.${indicator}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}` +
|
|
'</style>'
|
|
)
|
|
}
|
|
}
|
|
|
|
defineExtension(name, extension) {
|
|
if (this.#approvedExt && !this.#approvedExt.split(/,\s*/).includes(name)) return false;
|
|
if (this.#registeredExt.has(name)) return false;
|
|
this.#registeredExt.add(name);
|
|
if (extension.init) extension.init(this.#internalAPI);
|
|
Object.entries(extension).forEach(([key, value]) => {
|
|
if(!this.#extMethods.get(key)?.push(value)) this.#extMethods.set(key, [value]);
|
|
});
|
|
}
|
|
|
|
#ignore(elt) {
|
|
return !elt.closest || elt.closest(`[${this.#prefix("hx-ignore")}]`) != null
|
|
}
|
|
|
|
#prefix(s) {
|
|
return this.config.prefix ? s.replace('hx-', this.config.prefix) : s;
|
|
}
|
|
|
|
#queryEltAndDescendants(elt, selector) {
|
|
let results = [...elt.querySelectorAll(selector)];
|
|
if (elt.matches?.(selector)) {
|
|
results.unshift(elt);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
#normalizeSwapStyle(style) {
|
|
return style === 'before' ? 'beforebegin' :
|
|
style === 'after' ? 'afterend' :
|
|
style === 'prepend' ? 'afterbegin' :
|
|
style === 'append' ? 'beforeend' : style;
|
|
}
|
|
|
|
#attributeValue(elt, name, defaultVal, returnElt) {
|
|
name = this.#prefix(name);
|
|
let appendName = name + this.#maybeAdjustMetaCharacter(":append");
|
|
let inheritName = name + (this.config.implicitInheritance ? "" : this.#maybeAdjustMetaCharacter(":inherited"));
|
|
let inheritAppendName = name + this.#maybeAdjustMetaCharacter(":inherited:append");
|
|
|
|
if (elt.hasAttribute(name)) {
|
|
return returnElt ? elt : elt.getAttribute(name);
|
|
}
|
|
|
|
if (elt.hasAttribute(inheritName)) {
|
|
return returnElt ? elt : elt.getAttribute(inheritName);
|
|
}
|
|
|
|
if (elt.hasAttribute(appendName) || elt.hasAttribute(inheritAppendName)) {
|
|
let appendValue = elt.getAttribute(appendName) || elt.getAttribute(inheritAppendName);
|
|
let parent = elt.parentNode?.closest?.(`[${CSS.escape(inheritName)}],[${CSS.escape(inheritAppendName)}]`);
|
|
if (parent) {
|
|
let inherited = this.#attributeValue(parent, name, undefined, returnElt);
|
|
return returnElt ? inherited : (inherited ? inherited + "," + appendValue : appendValue);
|
|
} else {
|
|
return returnElt ? elt : appendValue;
|
|
}
|
|
}
|
|
|
|
let parent = elt.parentNode?.closest?.(`[${CSS.escape(inheritName)}],[${CSS.escape(inheritAppendName)}]`);
|
|
if (parent) {
|
|
let val = this.#attributeValue(parent, name, undefined, returnElt);
|
|
if (!returnElt && val && this.config.implicitInheritance) {
|
|
this.#triggerExtensions(elt, "htmx:after:implicitInheritance", {elt, parent})
|
|
}
|
|
return val;
|
|
}
|
|
return returnElt ? elt : defaultVal;
|
|
}
|
|
|
|
#tokenize(str) {
|
|
let tokens = [], i = 0;
|
|
while (i < str.length) {
|
|
let c = str[i];
|
|
if (c === '"' || c === "'") {
|
|
let q = c, s = c;
|
|
i++;
|
|
while (i < str.length) {
|
|
c = str[i];
|
|
s += c;
|
|
i++;
|
|
if (c === '\\' && i < str.length) {
|
|
s += str[i];
|
|
i++;
|
|
} else if (c === q) break;
|
|
}
|
|
tokens.push(s);
|
|
} else if (/\s/.test(c)) {
|
|
while (i < str.length && /\s/.test(str[i])) i++;
|
|
} else if (c === ':' || c === ',') {
|
|
tokens.push(c);
|
|
i++;
|
|
} else {
|
|
let t = '';
|
|
while (i < str.length && !/[\s"':,]/.test(str[i])) t += str[i++];
|
|
tokens.push(t);
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
#parseTriggerSpecs(spec) {
|
|
let specs = []
|
|
let currentSpec = null
|
|
let tokens = this.#tokenize(spec);
|
|
for (let i = 0; i < tokens.length; i++) {
|
|
let token = tokens[i];
|
|
if (token === ",") {
|
|
currentSpec = null;
|
|
} else if (!currentSpec) {
|
|
while (token.includes("[") && !token.includes("]") && i + 1 < tokens.length) {
|
|
token += tokens[++i];
|
|
}
|
|
if (token.includes("[") && !token.includes("]")) {
|
|
throw "unterminated:" + token;
|
|
}
|
|
currentSpec = {name: token};
|
|
specs.push(currentSpec);
|
|
} else if (tokens[i + 1] === ":") {
|
|
currentSpec[token] = tokens[i += 2];
|
|
} else {
|
|
currentSpec[token] = true;
|
|
}
|
|
}
|
|
|
|
return specs;
|
|
}
|
|
|
|
#determineMethodAndAction(elt, evt) {
|
|
if (this.#isBoosted(elt)) {
|
|
return this.#boostedMethodAndAction(elt, evt)
|
|
} else {
|
|
let method = this.#attributeValue(elt, "hx-method") || "GET"
|
|
let action = this.#attributeValue(elt, "hx-action");
|
|
if (!action) {
|
|
for (let verb of this.#verbs) {
|
|
let verbAction = this.#attributeValue(elt, "hx-" + verb);
|
|
if (verbAction) {
|
|
action = verbAction;
|
|
method = verb;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
method = method.toUpperCase()
|
|
return {action, method}
|
|
}
|
|
}
|
|
|
|
#boostedMethodAndAction(elt, evt) {
|
|
if (elt.matches("a")) {
|
|
return {action: elt.getAttribute("href"), method: "GET"}
|
|
} else {
|
|
let action = evt.submitter?.getAttribute?.("formAction") || elt.getAttribute("action");
|
|
let method = evt.submitter?.getAttribute?.("formMethod") || elt.getAttribute("method") || "GET";
|
|
return {action, method}
|
|
}
|
|
}
|
|
|
|
#initializeElement(elt) {
|
|
if (this.#shouldInitialize(elt) && this.#trigger(elt, "htmx:before:init", {}, true)) {
|
|
elt._htmx = {eventHandler: this.#createHtmxEventHandler(elt)}
|
|
elt.setAttribute('data-htmx-powered', 'true');
|
|
this.#initializeTriggers(elt);
|
|
this.#initializeStreamConfig(elt);
|
|
this.#initializeAbortListener(elt)
|
|
this.#trigger(elt, "htmx:after:init", {}, true)
|
|
this.#trigger(elt, "load", {}, false)
|
|
}
|
|
}
|
|
|
|
#createHtmxEventHandler(elt) {
|
|
return async (evt) => {
|
|
try {
|
|
let ctx = this.#createRequestContext(elt, evt);
|
|
await this.#handleTriggerEvent(ctx);
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
};
|
|
}
|
|
|
|
#createRequestContext(sourceElement, sourceEvent) {
|
|
let {action, method} = this.#determineMethodAndAction(sourceElement, sourceEvent);
|
|
let ctx = {
|
|
sourceElement,
|
|
sourceEvent,
|
|
status: "created",
|
|
select: this.#attributeValue(sourceElement, "hx-select"),
|
|
selectOOB: this.#attributeValue(sourceElement, "hx-select-oob"),
|
|
target: this.#attributeValue(sourceElement, "hx-target"),
|
|
swap: this.#attributeValue(sourceElement, "hx-swap", this.config.defaultSwap),
|
|
push: this.#attributeValue(sourceElement, "hx-push-url"),
|
|
replace: this.#attributeValue(sourceElement, "hx-replace-url"),
|
|
transition: this.config.transitions,
|
|
request: {
|
|
validate: "true" === this.#attributeValue(sourceElement, "hx-validate", sourceElement.matches('form') ? "true" : "false"),
|
|
action,
|
|
method,
|
|
headers: this.#determineHeaders(sourceElement)
|
|
}
|
|
};
|
|
|
|
// Apply hx-config overrides
|
|
let configAttr = this.#attributeValue(sourceElement, "hx-config");
|
|
if (configAttr) {
|
|
let configOverrides = JSON.parse(configAttr);
|
|
let requestConfig = ctx.request;
|
|
for (let key in configOverrides) {
|
|
if (key.startsWith('+')) {
|
|
let actualKey = key.substring(1);
|
|
if (requestConfig[actualKey] && typeof requestConfig[actualKey] === 'object') {
|
|
Object.assign(requestConfig[actualKey], configOverrides[key]);
|
|
} else {
|
|
requestConfig[actualKey] = configOverrides[key];
|
|
}
|
|
} else {
|
|
requestConfig[key] = configOverrides[key];
|
|
}
|
|
}
|
|
if (requestConfig.etag) {
|
|
sourceElement._htmx ||= {}
|
|
sourceElement._htmx.etag ||= requestConfig.etag
|
|
}
|
|
}
|
|
if (sourceElement._htmx?.etag) {
|
|
ctx.request.headers["If-none-match"] = sourceElement._htmx.etag
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
#determineHeaders(elt) {
|
|
let headers = {
|
|
"HX-Request": "true",
|
|
"Accept": "text/html, text/event-stream"
|
|
};
|
|
if (this.#isBoosted(elt)) {
|
|
headers["HX-Boosted"] = "true"
|
|
}
|
|
let headersAttribute = this.#attributeValue(elt, "hx-headers");
|
|
if (headersAttribute) {
|
|
Object.assign(headers, JSON.parse(headersAttribute));
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
#resolveTarget(elt, selector) {
|
|
if (selector instanceof Element) {
|
|
return selector;
|
|
} else if (selector === 'this') {
|
|
return this.#attributeValue(elt, "hx-target", undefined, true);
|
|
} else if (selector != null) {
|
|
return this.find(elt, selector);
|
|
} else if (this.#isBoosted(elt)) {
|
|
return document.body
|
|
} else {
|
|
return elt;
|
|
}
|
|
}
|
|
|
|
#isBoosted(elt) {
|
|
return elt?._htmx?.boosted;
|
|
}
|
|
|
|
async #handleTriggerEvent(ctx) {
|
|
let elt = ctx.sourceElement
|
|
let evt = ctx.sourceEvent
|
|
if (!elt.isConnected) return
|
|
|
|
if (this.#isModifierKeyClick(evt)) return
|
|
|
|
if (this.#shouldCancel(evt)) evt.preventDefault()
|
|
|
|
// Resolve swap target
|
|
ctx.target = this.#resolveTarget(elt, ctx.target);
|
|
|
|
// Build request body
|
|
let form = elt.form || elt.closest("form")
|
|
let body = this.#collectFormData(elt, form, evt.submitter)
|
|
this.#handleHxVals(elt, body)
|
|
if (ctx.values) {
|
|
for (let k in ctx.values) {
|
|
body.delete(k);
|
|
body.append(k, ctx.values[k]);
|
|
}
|
|
}
|
|
|
|
// Setup abort controller and action
|
|
let ac = new AbortController()
|
|
let action = ctx.request.action.replace?.(/#.*$/, '')
|
|
// TODO - consider how this works with hx-config, move most to #createRequestContext?
|
|
Object.assign(ctx.request, {
|
|
originalAction: ctx.request.action,
|
|
action,
|
|
form,
|
|
submitter: evt.submitter,
|
|
abort: ac.abort.bind(ac),
|
|
body,
|
|
credentials: "same-origin",
|
|
signal: ac.signal,
|
|
mode: this.config.mode
|
|
})
|
|
|
|
if (!this.#trigger(elt, "htmx:config:request", {ctx: ctx})) return
|
|
if (!this.#verbs.includes(ctx.request.method.toLowerCase())) return
|
|
if (ctx.request.validate && ctx.request.form && !ctx.request.form.reportValidity()) return
|
|
|
|
let javascriptContent = this.#extractJavascriptContent(ctx.request.action);
|
|
if (javascriptContent) {
|
|
let data = Object.fromEntries(ctx.request.body);
|
|
await this.#executeJavaScriptAsync(ctx.sourceElement, data, javascriptContent, false);
|
|
return
|
|
} else if (/GET|DELETE/.test(ctx.request.method)) {
|
|
let params = new URLSearchParams(ctx.request.body);
|
|
if (params.size) ctx.request.action += (/\?/.test(ctx.request.action) ? "&" : "?") + params
|
|
ctx.request.body = null
|
|
} else if (this.#attributeValue(elt, "hx-encoding") !== "multipart/form-data") {
|
|
ctx.request.body = new URLSearchParams(ctx.request.body);
|
|
}
|
|
|
|
await this.#issueRequest(ctx);
|
|
}
|
|
|
|
async #issueRequest(ctx) {
|
|
let elt = ctx.sourceElement
|
|
let syncStrategy = this.#determineSyncStrategy(elt);
|
|
let requestQueue = this.#getRequestQueue(elt);
|
|
|
|
if (!requestQueue.issue(ctx, syncStrategy)) return
|
|
|
|
ctx.status = "issuing"
|
|
this.#initTimeout(ctx);
|
|
|
|
let indicatorsSelector = this.#attributeValue(elt, "hx-indicator");
|
|
let indicators = this.#showIndicators(elt, indicatorsSelector);
|
|
let disableSelector = this.#attributeValue(elt, "hx-disable");
|
|
let disableElements = this.#disableElements(elt, disableSelector);
|
|
|
|
try {
|
|
// Confirm dialog
|
|
let confirmVal = this.#attributeValue(elt, 'hx-confirm');
|
|
if (confirmVal) {
|
|
let js = this.#extractJavascriptContent(confirmVal);
|
|
if (js) {
|
|
if (!await this.#executeJavaScriptAsync(elt, {}, js, true)) {
|
|
return
|
|
}
|
|
} else {
|
|
if (!window.confirm(confirmVal)) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.fetch ||= window.fetch.bind(window)
|
|
if (!this.#trigger(elt, "htmx:before:request", {ctx})) return;
|
|
|
|
let response = await ctx.fetch(ctx.request.action, ctx.request);
|
|
|
|
ctx.response = {
|
|
raw: response,
|
|
status: response.status,
|
|
headers: response.headers,
|
|
}
|
|
this.#extractHxHeaders(ctx);
|
|
ctx.isSSE = response.headers.get("Content-Type")?.includes('text/event-stream');
|
|
if (!ctx.isSSE) {
|
|
ctx.text = await response.text();
|
|
}
|
|
if (!this.#trigger(elt, "htmx:after:request", {ctx})) return;
|
|
|
|
if(this.#handleHeadersAndMaybeReturnEarly(ctx)){
|
|
return
|
|
}
|
|
|
|
let isSSE = response.headers.get("Content-Type")?.includes('text/event-stream');
|
|
if (isSSE) {
|
|
// SSE response
|
|
await this.#handleSSE(ctx, elt, response);
|
|
} else {
|
|
// HTTP response
|
|
if (ctx.status === "issuing") {
|
|
if (ctx.hx.retarget) ctx.target = this.#resolveTarget(elt, ctx.hx.retarget);
|
|
if (ctx.hx.reswap) ctx.swap = ctx.hx.reswap;
|
|
if (ctx.hx.reselect) ctx.select = ctx.hx.reselect;
|
|
ctx.status = "response received";
|
|
this.#handleStatusCodes(ctx);
|
|
await this.swap(ctx);
|
|
ctx.status = "swapped";
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
ctx.status = "error: " + error;
|
|
this.#trigger(elt, "htmx:error", {ctx, error})
|
|
} finally {
|
|
this.#hideIndicators(indicators);
|
|
this.#enableElements(disableElements);
|
|
this.#trigger(elt, "htmx:finally:request", {ctx})
|
|
|
|
requestQueue.finish()
|
|
if (requestQueue.more()) {
|
|
// TODO is it OK to not await here? try/catch?
|
|
this.#issueRequest(requestQueue.next())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract HX-* headers into ctx.hx
|
|
#extractHxHeaders(ctx) {
|
|
ctx.hx = {}
|
|
for (let [k, v] of ctx.response.raw.headers) {
|
|
if (k.toLowerCase().startsWith('hx-')) {
|
|
ctx.hx[k.slice(3).toLowerCase().replace(/-/g, '')] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// returns true if the header aborts the current response handling
|
|
#handleHeadersAndMaybeReturnEarly(ctx) {
|
|
if (ctx.hx.trigger) {
|
|
this.#handleTriggerHeader(ctx.hx.trigger, ctx.sourceElement);
|
|
}
|
|
if (ctx.hx.refresh === 'true') {
|
|
location.reload();
|
|
return true // TODO - necessary? wouldn't it abort the current js?
|
|
}
|
|
if (ctx.hx.redirect) {
|
|
location.href = ctx.hx.redirect;
|
|
return true // TODO - same, necessary?
|
|
}
|
|
if (ctx.hx.location) {
|
|
let path = ctx.hx.location, opts = {};
|
|
if (path[0] === '{') {
|
|
opts = JSON.parse(path);
|
|
path = opts.path;
|
|
delete opts.path;
|
|
}
|
|
opts.push = opts.push || 'true';
|
|
this.ajax('GET', path, opts);
|
|
return true // TODO this seems legit
|
|
}
|
|
if(ctx.response?.headers?.get?.("Etag")) {
|
|
ctx.sourceElement._htmx ||= {}
|
|
ctx.sourceElement._htmx.etag = ctx.response.headers.get("Etag");
|
|
}
|
|
}
|
|
|
|
async #handleSSE(ctx, elt, response) {
|
|
let config = elt._htmx?.streamConfig || {...this.config.streams};
|
|
|
|
let waitForVisible = () => new Promise(r => {
|
|
let onVisible = () => !document.hidden && (document.removeEventListener('visibilitychange', onVisible), r());
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
});
|
|
|
|
let lastEventId = null, attempt = 0, currentResponse = response;
|
|
|
|
while (elt.isConnected) {
|
|
// Handle reconnection for subsequent iterations
|
|
if (attempt > 0) {
|
|
if (config.mode !== 'continuous' || attempt > config.maxRetries) break;
|
|
|
|
if (config.pauseHidden && document.hidden) {
|
|
await waitForVisible();
|
|
if (!elt.isConnected) break;
|
|
}
|
|
|
|
let delay = Math.min(config.initialDelay * Math.pow(2, attempt - 1), config.maxDelay);
|
|
let reconnect = {attempt, delay, lastEventId, cancelled: false};
|
|
|
|
ctx.status = "reconnecting to stream";
|
|
if (!this.#trigger(elt, "htmx:before:sse:reconnect", {
|
|
ctx,
|
|
reconnect
|
|
}) || reconnect.cancelled) break;
|
|
|
|
await new Promise(r => setTimeout(r, reconnect.delay));
|
|
if (!elt.isConnected) break;
|
|
|
|
try {
|
|
if (lastEventId) (ctx.request.headers = ctx.request.headers || {})['Last-Event-ID'] = lastEventId;
|
|
currentResponse = await fetch(ctx.request.action, ctx.request);
|
|
} catch (e) {
|
|
ctx.status = "stream error";
|
|
this.#trigger(elt, "htmx:error", {ctx, error: e});
|
|
attempt++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Core streaming logic
|
|
if (!this.#trigger(elt, "htmx:before:sse:stream", {ctx})) break;
|
|
ctx.status = "streaming";
|
|
|
|
attempt = 0; // Reset on successful connection
|
|
|
|
try {
|
|
for await (const sseMessage of this.#parseSSE(currentResponse)) {
|
|
if (!elt.isConnected) break;
|
|
|
|
if (config.pauseHidden && document.hidden) {
|
|
await waitForVisible();
|
|
if (!elt.isConnected) break;
|
|
}
|
|
|
|
let msg = {data: sseMessage.data, event: sseMessage.event, id: sseMessage.id, cancelled: false};
|
|
if (!this.#trigger(elt, "htmx:before:sse:message", {
|
|
ctx,
|
|
message: msg
|
|
}) || msg.cancelled) continue;
|
|
|
|
if (sseMessage.id) lastEventId = sseMessage.id;
|
|
|
|
// Trigger custom event if `event:` line is present
|
|
if (sseMessage.event) {
|
|
this.#trigger(elt, sseMessage.event, {data: sseMessage.data, id: sseMessage.id});
|
|
// Skip swap for custom events
|
|
this.#trigger(elt, "htmx:after:sse:message", {ctx, message: msg});
|
|
continue;
|
|
}
|
|
|
|
ctx.text = sseMessage.data;
|
|
ctx.status = "stream message received";
|
|
|
|
if (!ctx.response.cancelled) {
|
|
await this.swap(ctx);
|
|
ctx.status = "swapped";
|
|
}
|
|
this.#trigger(elt, "htmx:after:sse:message", {ctx, message: msg});
|
|
}
|
|
} catch (e) {
|
|
ctx.status = "stream error";
|
|
this.#trigger(elt, "htmx:error", {ctx, error: e});
|
|
}
|
|
|
|
if (!elt.isConnected) break;
|
|
this.#trigger(elt, "htmx:after:sse:stream", {ctx});
|
|
|
|
attempt++;
|
|
}
|
|
}
|
|
|
|
async* #parseSSE(response) {
|
|
let reader = response.body.getReader();
|
|
let decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let message = {data: '', event: '', id: '', retry: null};
|
|
|
|
try {
|
|
while (true) {
|
|
let {done, value} = await reader.read();
|
|
if (done) break;
|
|
|
|
// Decode chunk and add to buffer
|
|
buffer += decoder.decode(value, {stream: true});
|
|
let lines = buffer.split('\n');
|
|
// Keep incomplete line in buffer
|
|
buffer = lines.pop() || '';
|
|
|
|
for (let line of lines) {
|
|
// Empty line or carriage return indicates end of message
|
|
if (!line || line === '\r') {
|
|
if (message.data) {
|
|
yield message;
|
|
message = {data: '', event: '', id: '', retry: null};
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Parse field: value
|
|
let colonIndex = line.indexOf(':');
|
|
if (colonIndex <= 0) continue;
|
|
|
|
let field = line.slice(0, colonIndex);
|
|
let value = line.slice(colonIndex + 1).trimStart();
|
|
|
|
if (field === 'data') {
|
|
message.data += (message.data ? '\n' : '') + value;
|
|
} else if (field === 'event') {
|
|
message.event = value;
|
|
} else if (field === 'id') {
|
|
message.id = value;
|
|
} else if (field === 'retry') {
|
|
let retryValue = parseInt(value, 10);
|
|
if (!isNaN(retryValue)) {
|
|
message.retry = retryValue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
#initTimeout(ctx) {
|
|
let timeoutInterval;
|
|
if (ctx.request.timeout) {
|
|
timeoutInterval = typeof ctx.request.timeout == "string" ? this.parseInterval(ctx.request.timeout) : ctx.request.timeout;
|
|
} else {
|
|
timeoutInterval = this.config.defaultTimeout;
|
|
}
|
|
ctx.requestTimeout = setTimeout(() => ctx.abort?.(), timeoutInterval);
|
|
}
|
|
|
|
#determineSyncStrategy(elt) {
|
|
let syncValue = this.#attributeValue(elt, "hx-sync");
|
|
return syncValue?.split(":")[1] || "queue first";
|
|
}
|
|
|
|
#getRequestQueue(elt) {
|
|
let syncValue = this.#attributeValue(elt, "hx-sync");
|
|
let syncElt = elt
|
|
if (syncValue && syncValue.includes(":")) {
|
|
let strings = syncValue.split(":");
|
|
let selector = strings[0];
|
|
syncElt = this.#findExt(selector);
|
|
}
|
|
return syncElt._htmxRequestQueue ||= new ReqQ()
|
|
}
|
|
|
|
#isModifierKeyClick(evt) {
|
|
return evt.type === 'click' && (evt.ctrlKey || evt.metaKey || evt.shiftKey)
|
|
}
|
|
|
|
#shouldCancel(evt) {
|
|
let elt = evt.currentTarget
|
|
let isSubmit = evt.type === 'submit' && elt?.tagName === 'FORM'
|
|
if (isSubmit) return true
|
|
|
|
let isClick = evt.type === 'click' && evt.button === 0
|
|
if (!isClick) return false
|
|
|
|
let btn = elt?.closest?.('button, input[type="submit"], input[type="image"]')
|
|
let form = btn?.form || btn?.closest('form')
|
|
let isSubmitButton = btn && !btn.disabled && form &&
|
|
(btn.type === 'submit' || btn.type === 'image' || (!btn.type && btn.tagName === 'BUTTON'))
|
|
if (isSubmitButton) return true
|
|
|
|
let link = elt?.closest?.('a')
|
|
if (!link || !link.href) return false
|
|
|
|
let href = link.getAttribute('href')
|
|
let isFragmentOnly = href && href.startsWith('#') && href.length > 1
|
|
return !isFragmentOnly
|
|
}
|
|
|
|
#initializeTriggers(elt, initialHandler = elt._htmx.eventHandler) {
|
|
let specString = this.#attributeValue(elt, "hx-trigger");
|
|
if (!specString) {
|
|
specString = elt.matches("form") ? "submit" :
|
|
elt.matches("input:not([type=button]),select,textarea") ? "change" :
|
|
"click";
|
|
}
|
|
elt._htmx.triggerSpecs = this.#parseTriggerSpecs(specString)
|
|
elt._htmx.listeners = []
|
|
for (let spec of elt._htmx.triggerSpecs) {
|
|
spec.handler = initialHandler
|
|
spec.listeners = []
|
|
spec.values = {}
|
|
|
|
let [eventName, filter] = this.#extractFilter(spec.name);
|
|
|
|
// should be first so logic is called only when all other filters pass
|
|
if (spec.once) {
|
|
let original = spec.handler
|
|
spec.handler = (evt) => {
|
|
original(evt)
|
|
for (let listenerInfo of spec.listeners) {
|
|
listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (eventName === 'intersect' || eventName === "revealed") {
|
|
let observerOptions = {}
|
|
if (spec.opts?.root) {
|
|
observerOptions.root = this.#findExt(elt, spec.opts.root)
|
|
}
|
|
if (spec.opts?.threshold) {
|
|
observerOptions.threshold = parseFloat(spec.opts.threshold)
|
|
}
|
|
let isRevealed = eventName === "revealed"
|
|
spec.observer = new IntersectionObserver((entries) => {
|
|
for (let i = 0; i < entries.length; i++) {
|
|
let entry = entries[i]
|
|
if (entry.isIntersecting) {
|
|
this.trigger(elt, 'intersect', {}, false)
|
|
if (isRevealed) {
|
|
spec.observer.disconnect()
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}, observerOptions)
|
|
eventName = "intersect"
|
|
spec.observer.observe(elt)
|
|
}
|
|
|
|
if (spec.delay) {
|
|
let original = spec.handler
|
|
spec.handler = evt => {
|
|
clearTimeout(spec.timeout)
|
|
spec.timeout = setTimeout(() => original(evt),
|
|
this.parseInterval(spec.delay));
|
|
}
|
|
}
|
|
|
|
if (spec.throttle) {
|
|
let original = spec.handler
|
|
spec.handler = evt => {
|
|
if (spec.throttled) {
|
|
spec.throttledEvent = evt
|
|
} else {
|
|
spec.throttled = true
|
|
original(evt);
|
|
spec.throttleTimeout = setTimeout(() => {
|
|
spec.throttled = false
|
|
if (spec.throttledEvent) {
|
|
// implement trailing-edge throttling
|
|
let throttledEvent = spec.throttledEvent;
|
|
spec.throttledEvent = null
|
|
spec.handler(throttledEvent);
|
|
}
|
|
}, this.parseInterval(spec.throttle))
|
|
}
|
|
}
|
|
}
|
|
|
|
if (spec.target) {
|
|
let original = spec.handler
|
|
spec.handler = evt => {
|
|
if (evt.target?.matches?.(spec.target)) {
|
|
original(evt)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (eventName === "every") {
|
|
let interval = Object.keys(spec).find(k => k !== 'name');
|
|
spec.interval = setInterval(() => {
|
|
if (elt.isConnected) {
|
|
this.#trigger(elt, 'every', {}, false);
|
|
} else {
|
|
clearInterval(spec.interval)
|
|
}
|
|
}, this.parseInterval(interval));
|
|
}
|
|
|
|
if (filter) {
|
|
let original = spec.handler
|
|
spec.handler = (evt) => {
|
|
if (this.#shouldCancel(evt)) evt.preventDefault()
|
|
if (this.#executeFilter(elt, evt, filter)) {
|
|
original(evt)
|
|
}
|
|
}
|
|
}
|
|
|
|
let fromElts = [elt];
|
|
if (spec.from) {
|
|
fromElts = this.#findAllExt(elt, spec.from)
|
|
}
|
|
|
|
if (spec.consume) {
|
|
let original = spec.handler
|
|
spec.handler = (evt) => {
|
|
evt.stopPropagation()
|
|
original(evt)
|
|
}
|
|
}
|
|
|
|
if (spec.changed) {
|
|
let original = spec.handler
|
|
spec.handler = (evt) => {
|
|
let trigger = false
|
|
for (let fromElt of fromElts) {
|
|
if (spec.values[fromElt] !== fromElt.value) {
|
|
trigger = true
|
|
spec.values[fromElt] = fromElt.value
|
|
}
|
|
}
|
|
if (trigger) {
|
|
original(evt)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let fromElt of fromElts) {
|
|
let listenerInfo = {fromElt, eventName, handler: spec.handler};
|
|
elt._htmx.listeners.push(listenerInfo)
|
|
spec.listeners.push(listenerInfo)
|
|
fromElt.addEventListener(eventName, spec.handler);
|
|
}
|
|
}
|
|
}
|
|
|
|
#initializeStreamConfig(elt) {
|
|
let streamSpec = this.#attributeValue(elt, 'hx-stream');
|
|
if (!streamSpec) return;
|
|
|
|
// Start with global defaults
|
|
let streamConfig = {...this.config.streams};
|
|
let tokens = this.#tokenize(streamSpec);
|
|
|
|
for (let i = 0; i < tokens.length; i++) {
|
|
let token = tokens[i];
|
|
// Main value: once or continuous
|
|
if (token === 'once' || token === 'continuous') {
|
|
streamConfig.mode = token;
|
|
} else if (token === 'pauseHidden') {
|
|
streamConfig.pauseHidden = true;
|
|
} else if (tokens[i + 1] === ':') {
|
|
let key = token, value = tokens[i + 2];
|
|
if (key === 'mode') streamConfig.mode = value;
|
|
else if (key === 'maxRetries') streamConfig.maxRetries = parseInt(value);
|
|
else if (key === 'initialDelay') streamConfig.initialDelay = this.parseInterval(value);
|
|
else if (key === 'maxDelay') streamConfig.maxDelay = this.parseInterval(value);
|
|
else if (key === 'pauseHidden') streamConfig.pauseHidden = value === 'true';
|
|
i += 2;
|
|
}
|
|
}
|
|
|
|
if (!elt._htmx) elt._htmx = {};
|
|
elt._htmx.streamConfig = streamConfig;
|
|
}
|
|
|
|
#extractFilter(str) {
|
|
let match = str.match(/^([^\[]*)\[([^\]]*)]/);
|
|
if (!match) return [str, null];
|
|
return [match[1], match[2]];
|
|
}
|
|
|
|
#handleTriggerHeader(value, elt) {
|
|
if (value[0] === '{') {
|
|
let triggers = JSON.parse(value);
|
|
for (let name in triggers) {
|
|
let detail = triggers[name];
|
|
if (detail?.target) elt = this.find(detail.target) || elt;
|
|
this.trigger(elt, name, typeof detail === 'object' ? detail : {value: detail});
|
|
}
|
|
} else {
|
|
value.split(',').forEach(name => this.trigger(elt, name.trim(), {}));
|
|
}
|
|
}
|
|
|
|
#apiMethods(thisArg) {
|
|
let bound = {};
|
|
let proto = Object.getPrototypeOf(this);
|
|
for (let name of Object.getOwnPropertyNames(proto)) {
|
|
if (name !== 'constructor' && typeof this[name] === 'function') {
|
|
if (["find", "findAll"].includes(name)) {
|
|
bound[name] = (arg1, arg2) => {
|
|
if (arg2 === undefined) {
|
|
return this[name](thisArg, arg1)
|
|
} else {
|
|
return this[name](arg1, arg2)
|
|
}
|
|
}
|
|
} else {
|
|
bound[name] = this[name].bind(this);
|
|
}
|
|
}
|
|
}
|
|
return bound;
|
|
}
|
|
|
|
async #executeJavaScriptAsync(thisArg, obj, code, expression = true) {
|
|
let args = {}
|
|
Object.assign(args, this.#apiMethods(thisArg))
|
|
Object.assign(args, obj)
|
|
let keys = Object.keys(args);
|
|
let values = Object.values(args);
|
|
let AsyncFunction = Object.getPrototypeOf(async function () {
|
|
}).constructor;
|
|
let func = new AsyncFunction(...keys, expression ? `return (${code})` : code);
|
|
return await func.call(thisArg, ...values);
|
|
}
|
|
|
|
#executeFilter(thisArg, event, code) {
|
|
let args = {}
|
|
Object.assign(args, this.#apiMethods(thisArg))
|
|
for (let key in event) {
|
|
args[key] = event[key];
|
|
}
|
|
let keys = Object.keys(args);
|
|
let values = Object.values(args);
|
|
let func = new Function(...keys, `return (${code})`);
|
|
return func.call(thisArg, ...values);
|
|
}
|
|
|
|
process(elt) {
|
|
if (!elt || this.#ignore(elt)) return;
|
|
if (!this.#trigger(elt, "htmx:before:process")) return
|
|
for (let child of this.#queryEltAndDescendants(elt, this.#actionSelector)) {
|
|
this.#initializeElement(child);
|
|
}
|
|
for (let child of this.#queryEltAndDescendants(elt, this.#boostSelector)) {
|
|
this.#maybeBoost(child);
|
|
}
|
|
this.#handleHxOnAttributes(elt);
|
|
let iter = this.#hxOnQuery.evaluate(elt)
|
|
let node = null
|
|
while (node = iter.iterateNext()) this.#handleHxOnAttributes(node)
|
|
this.#trigger(elt, "htmx:after:process");
|
|
}
|
|
|
|
#maybeBoost(elt) {
|
|
if (this.#attributeValue(elt, "hx-boost") === "true" && this.#shouldBoost(elt)) {
|
|
elt._htmx = {eventHandler: this.#createHtmxEventHandler(elt), requests: [], boosted: true}
|
|
elt.setAttribute('data-htmx-powered', 'true');
|
|
if (elt.matches('a') && !elt.hasAttribute("target")) {
|
|
elt.addEventListener('click', (click) => {
|
|
elt._htmx.eventHandler(click)
|
|
})
|
|
} else {
|
|
elt.addEventListener('submit', (submit) => {
|
|
elt._htmx.eventHandler(submit)
|
|
})
|
|
}
|
|
this.#trigger(elt, "htmx:after:init", {}, true)
|
|
}
|
|
}
|
|
|
|
#shouldBoost(elt) {
|
|
if (this.#shouldInitialize(elt)) {
|
|
if (elt.tagName === "A") {
|
|
if (elt.target === '' || elt.target === '_self') {
|
|
return !elt.getAttribute('href')?.startsWith?.("#") && this.#isSameOrigin(elt.href)
|
|
}
|
|
} else if (elt.tagName === "FORM") {
|
|
return elt.method !== 'dialog' && this.#isSameOrigin(elt.action);
|
|
}
|
|
}
|
|
}
|
|
|
|
#isSameOrigin(url) {
|
|
try {
|
|
// URL constructor handles both relative and absolute URLs
|
|
const parsed = new URL(url, window.location.href);
|
|
return parsed.origin === window.location.origin;
|
|
} catch (e) {
|
|
// If URL parsing fails, assume not same-origin
|
|
return false;
|
|
}
|
|
}
|
|
|
|
#shouldInitialize(elt) {
|
|
return !elt._htmx && !this.#ignore(elt);
|
|
}
|
|
|
|
#cleanup(elt) {
|
|
if (elt._htmx) {
|
|
this.#trigger(elt, "htmx:before:cleanup")
|
|
if (elt._htmx.interval) clearInterval(elt._htmx.interval);
|
|
for (let spec of elt._htmx.triggerSpecs || []) {
|
|
if (spec.interval) clearInterval(spec.interval);
|
|
if (spec.timeout) clearTimeout(spec.timeout);
|
|
}
|
|
for (let listenerInfo of elt._htmx.listeners || []) {
|
|
listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler);
|
|
}
|
|
this.#trigger(elt, "htmx:after:cleanup")
|
|
}
|
|
for (let child of elt.querySelectorAll('[data-htmx-powered]')) {
|
|
this.#cleanup(child);
|
|
}
|
|
}
|
|
|
|
#handlePreservedElements(fragment) {
|
|
let pantry = document.createElement('div');
|
|
pantry.style.display = 'none';
|
|
document.body.appendChild(pantry);
|
|
let newPreservedElts = fragment.querySelectorAll?.(`[${this.#prefix('hx-preserve')}]`) || [];
|
|
for (let preservedElt of newPreservedElts) {
|
|
let currentElt = document.getElementById(preservedElt.id);
|
|
if (pantry.moveBefore) {
|
|
pantry.moveBefore(currentElt, null);
|
|
} else {
|
|
pantry.appendChild(currentElt);
|
|
}
|
|
}
|
|
return pantry
|
|
}
|
|
|
|
#restorePreservedElements(pantry) {
|
|
for (let preservedElt of pantry.children) {
|
|
let newElt = document.getElementById(preservedElt.id);
|
|
if (newElt.parentNode.moveBefore) {
|
|
newElt.parentNode.moveBefore(preservedElt, newElt);
|
|
} else {
|
|
newElt.replaceWith(preservedElt);
|
|
}
|
|
this.#cleanup(newElt)
|
|
newElt.remove()
|
|
}
|
|
pantry.remove();
|
|
}
|
|
|
|
#parseHTML(resp) {
|
|
return Document.parseHTMLUnsafe?.(resp) || new DOMParser().parseFromString(resp, 'text/html');
|
|
}
|
|
|
|
#makeFragment(text) {
|
|
let response = text.replace(/<hx-partial(\s+|>)/gi, '<template partial$1').replace(/<\/hx-partial>/gi, '</template>');
|
|
let title = '';
|
|
response = response.replace(/<title[^>]*>[\s\S]*?<\/title>/i, m => (title = this.#parseHTML(m).title, ''));
|
|
let responseWithNoHead = response.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i, '');
|
|
let startTag = responseWithNoHead.match(/<([a-z][^\/>\x20\t\r\n\f]*)/i)?.[1]?.toLowerCase();
|
|
|
|
let doc, fragment;
|
|
if (startTag === 'html') {
|
|
doc = this.#parseHTML(response);
|
|
fragment = doc.body;
|
|
} else if (startTag === 'body') {
|
|
doc = this.#parseHTML(responseWithNoHead);
|
|
fragment = doc.body;
|
|
} else {
|
|
doc = this.#parseHTML(`<template>${responseWithNoHead}</template>`);
|
|
fragment = doc.querySelector('template').content;
|
|
}
|
|
this.#processScripts(fragment);
|
|
|
|
return {
|
|
fragment,
|
|
title
|
|
};
|
|
}
|
|
|
|
#createOOBTask(tasks, elt, oobValue, sourceElement) {
|
|
let target = elt.id ? '#' + CSS.escape(elt.id) : null;
|
|
if (oobValue !== 'true' && oobValue && !oobValue.includes(' ')) {
|
|
[oobValue, target = target] = oobValue.split(/:(.*)/);
|
|
}
|
|
if (oobValue === 'true' || !oobValue) oobValue = 'outerHTML';
|
|
|
|
let swapSpec = this.#parseSwapSpec(oobValue);
|
|
target = swapSpec.target || target;
|
|
swapSpec.strip ??= !swapSpec.style.startsWith('outer');
|
|
if (!target) return;
|
|
let fragment = document.createDocumentFragment();
|
|
fragment.append(elt);
|
|
tasks.push({type: 'oob', fragment, target, swapSpec, sourceElement});
|
|
}
|
|
|
|
#processOOB(fragment, sourceElement, selectOOB) {
|
|
let tasks = [];
|
|
|
|
// Process hx-select-oob first (select elements from response)
|
|
if (selectOOB) {
|
|
for (let spec of selectOOB.split(',')) {
|
|
let [selector, oobValue = 'true'] = spec.split(/:(.*)/);
|
|
for (let elt of fragment.querySelectorAll(selector)) {
|
|
this.#createOOBTask(tasks, elt, oobValue, sourceElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process elements with hx-swap-oob attribute
|
|
for (let oobElt of fragment.querySelectorAll(`[${this.#prefix('hx-swap-oob')}]`)) {
|
|
let oobValue = oobElt.getAttribute(this.#prefix('hx-swap-oob'));
|
|
oobElt.removeAttribute(this.#prefix('hx-swap-oob'));
|
|
this.#createOOBTask(tasks, oobElt, oobValue, sourceElement);
|
|
}
|
|
|
|
return tasks;
|
|
}
|
|
|
|
#insertNodes(parent, before, fragment) {
|
|
if (before) {
|
|
before.before(...fragment.childNodes);
|
|
} else {
|
|
parent.append(...fragment.childNodes);
|
|
}
|
|
}
|
|
|
|
#parseSwapSpec(swapStr) {
|
|
let tokens = this.#tokenize(swapStr);
|
|
let config = {style: tokens[1] === ':' ? this.config.defaultSwap : (tokens[0] || this.config.defaultSwap)};
|
|
config.style = this.#normalizeSwapStyle(config.style);
|
|
let startIdx = tokens[1] === ':' ? 0 : 1;
|
|
|
|
for (let i = startIdx; i < tokens.length; i++) {
|
|
if (tokens[i + 1] === ':') {
|
|
let key = tokens[i], value = tokens[i = i + 2];
|
|
if (key === 'swap') config.swapDelay = this.parseInterval(value);
|
|
else if (key === 'transition' || key === 'ignoreTitle' || key === 'strip') config[key] = value === 'true';
|
|
else if (key === 'focus-scroll') config.focusScroll = value === 'true';
|
|
else if (key === 'scroll' || key === 'show') {
|
|
let parts = [value];
|
|
while (tokens[i + 1] === ':') {
|
|
parts.push(tokens[i + 2]);
|
|
i += 2;
|
|
}
|
|
config[key] = parts.length === 1 ? parts[0] : parts.pop();
|
|
if (parts.length > 1) config[key + 'Target'] = parts.join(':');
|
|
} else if (key === 'target') {
|
|
let parts = [value];
|
|
while (i + 1 < tokens.length && tokens[i + 1] !== ':' && tokens[i + 2] !== ':') {
|
|
parts.push(tokens[i + 1]);
|
|
i++;
|
|
}
|
|
config[key] = parts.join(' ');
|
|
}
|
|
}
|
|
}
|
|
return config;
|
|
}
|
|
|
|
#processPartials(fragment, sourceElement) {
|
|
let tasks = [];
|
|
|
|
for (let partialElt of fragment.querySelectorAll('template[partial]')) {
|
|
let swapSpec = this.#parseSwapSpec(partialElt.getAttribute(this.#prefix('hx-swap')) || this.config.defaultSwap);
|
|
|
|
tasks.push({
|
|
type: 'partial',
|
|
fragment: partialElt.content.cloneNode(true),
|
|
target: partialElt.getAttribute(this.#prefix('hx-target')),
|
|
swapSpec,
|
|
sourceElement
|
|
});
|
|
partialElt.remove();
|
|
}
|
|
|
|
return tasks;
|
|
}
|
|
|
|
#handleAutoFocus(elt) {
|
|
let autofocus = this.find(elt, "[autofocus]");
|
|
autofocus?.focus?.()
|
|
}
|
|
|
|
#handleScroll(task) {
|
|
if (task.swapSpec.scroll) {
|
|
let target;
|
|
let [selectorOrValue, value] = task.swapSpec.scroll.split(":");
|
|
if (value) {
|
|
target = this.#findExt(selectorOrValue);
|
|
} else {
|
|
target = task.target;
|
|
value = selectorOrValue
|
|
}
|
|
if (value === 'top') {
|
|
target.scrollTop = 0;
|
|
} else if (value === 'bottom'){
|
|
target.scrollTop = target.scrollHeight;
|
|
}
|
|
}
|
|
if (task.swapSpec.show) {
|
|
let target;
|
|
let [selectorOrValue, value] = task.swapSpec.show.split(":");
|
|
if (value) {
|
|
target = this.#findExt(selectorOrValue);
|
|
} else {
|
|
target = task.target;
|
|
value = selectorOrValue
|
|
}
|
|
target.scrollIntoView(value === 'top')
|
|
}
|
|
}
|
|
|
|
#handleAnchorScroll(ctx) {
|
|
let anchor = ctx.request?.originalAction?.split('#')[1];
|
|
if (anchor) {
|
|
document.getElementById(anchor)?.scrollIntoView({block: 'start', behavior: 'auto'});
|
|
}
|
|
}
|
|
|
|
#processScripts(container) {
|
|
let scripts = this.#queryEltAndDescendants(container, 'script');
|
|
for (let oldScript of scripts) {
|
|
let newScript = document.createElement('script');
|
|
for (let attr of oldScript.attributes) {
|
|
newScript.setAttribute(attr.name, attr.value);
|
|
}
|
|
if (this.config.inlineScriptNonce) {
|
|
newScript.nonce = this.config.inlineScriptNonce;
|
|
}
|
|
newScript.textContent = oldScript.textContent;
|
|
oldScript.replaceWith(newScript);
|
|
}
|
|
}
|
|
|
|
//============================================================================================
|
|
// Public JS API
|
|
//============================================================================================
|
|
|
|
async swap(ctx) {
|
|
this.#handleHistoryUpdate(ctx);
|
|
let {fragment, title} = this.#makeFragment(ctx.text);
|
|
ctx.title = title;
|
|
let tasks = [];
|
|
|
|
// Process OOB and partials
|
|
let oobTasks = this.#processOOB(fragment, ctx.sourceElement, ctx.selectOOB);
|
|
let partialTasks = this.#processPartials(fragment, ctx.sourceElement);
|
|
tasks.push(...oobTasks, ...partialTasks);
|
|
|
|
// Process main swap
|
|
let mainSwap = this.#processMainSwap(ctx, fragment, partialTasks);
|
|
if (mainSwap) {
|
|
tasks.push(mainSwap);
|
|
}
|
|
|
|
// TODO - can we remove this and just let the function complete?
|
|
if (tasks.length === 0) return;
|
|
|
|
// Separate transition/nonTransition tasks
|
|
let transitionTasks = tasks.filter(t => t.transition);
|
|
let nonTransitionTasks = tasks.filter(t => !t.transition);
|
|
|
|
if(!this.#trigger(document, "htmx:before:swap", {ctx, tasks})){
|
|
return
|
|
}
|
|
|
|
// insert non-transition tasks immediately or with delay
|
|
for (let task of nonTransitionTasks) {
|
|
if (task.swapSpec?.swapDelay) {
|
|
setTimeout(() => this.#insertContent(task), task.swapSpec.swapDelay);
|
|
} else {
|
|
this.#insertContent(task)
|
|
}
|
|
}
|
|
|
|
// insert transition tasks in the transition queue
|
|
if (transitionTasks.length > 0) {
|
|
let tasksWrapper = ()=> {
|
|
for (let task of transitionTasks) {
|
|
this.#insertContent(task)
|
|
}
|
|
}
|
|
await this.#submitTransitionTask(tasksWrapper);
|
|
}
|
|
|
|
this.#trigger(document, "htmx:after:swap", {ctx});
|
|
if (ctx.title && !mainSwap?.swapSpec?.ignoreTitle) document.title = ctx.title;
|
|
await this.timeout(1);
|
|
// invoke restore tasks
|
|
for (let task of tasks) {
|
|
for (let restore of task.restoreTasks || []) {
|
|
restore()
|
|
}
|
|
}
|
|
this.#trigger(document, "htmx:after:restore", { ctx });
|
|
this.#handleAnchorScroll(ctx);
|
|
// TODO this stuff should be an extension
|
|
// if (ctx.hx?.triggerafterswap) this.#handleTriggerHeader(ctx.hx.triggerafterswap, ctx.sourceElement);
|
|
}
|
|
|
|
#processMainSwap(ctx, fragment, partialTasks) {
|
|
// Create main task if needed
|
|
let swapSpec = this.#parseSwapSpec(ctx.swap || this.config.defaultSwap);
|
|
// skip creating main swap if extracting partials resulted in empty response except for delete style
|
|
if (swapSpec.style === 'delete' || /\S/.test(fragment.innerHTML || '') || !partialTasks.length) {
|
|
if (ctx.select) {
|
|
let selected = fragment.querySelectorAll(ctx.select);
|
|
fragment = document.createDocumentFragment();
|
|
fragment.append(...selected);
|
|
}
|
|
if (this.#isBoosted(ctx.sourceElement)) {
|
|
swapSpec.show ||= 'top';
|
|
}
|
|
let mainSwap = {
|
|
type: 'main',
|
|
fragment,
|
|
target: swapSpec.target || ctx.target,
|
|
swapSpec,
|
|
sourceElement: ctx.sourceElement,
|
|
transition: (ctx.transition !== false) && (swapSpec.transition !== false)
|
|
};
|
|
return mainSwap;
|
|
}
|
|
}
|
|
|
|
#insertContent(task) {
|
|
let {target, swapSpec, fragment} = task;
|
|
if (typeof target === 'string') {
|
|
target = document.querySelector(target);
|
|
}
|
|
if (!target) return;
|
|
if (swapSpec.strip && fragment.firstElementChild) {
|
|
task.unstripped = fragment;
|
|
fragment = document.createDocumentFragment();
|
|
fragment.append(...(task.fragment.firstElementChild.content || task.fragment.firstElementChild).childNodes);
|
|
}
|
|
|
|
let pantry = this.#handlePreservedElements(fragment);
|
|
let parentNode = target.parentNode;
|
|
let newContent = [...fragment.childNodes]
|
|
if (swapSpec.style === 'innerHTML') {
|
|
this.#captureCSSTransitions(task, target);
|
|
for (const child of target.children) {
|
|
this.#cleanup(child)
|
|
}
|
|
target.replaceChildren(...fragment.childNodes);
|
|
} else if (swapSpec.style === 'outerHTML') {
|
|
if (parentNode) {
|
|
this.#captureCSSTransitions(task, parentNode);
|
|
this.#insertNodes(parentNode, target, fragment);
|
|
this.#cleanup(target)
|
|
parentNode.removeChild(target);
|
|
}
|
|
} else if (swapSpec.style === 'innerMorph') {
|
|
this.#morph(target, fragment, true);
|
|
} else if (swapSpec.style === 'outerMorph') {
|
|
this.#morph(target, fragment, false);
|
|
} else if (swapSpec.style === 'beforebegin') {
|
|
if (parentNode) {
|
|
this.#insertNodes(parentNode, target, fragment);
|
|
}
|
|
} else if (swapSpec.style === 'afterbegin') {
|
|
this.#insertNodes(target, target.firstChild, fragment);
|
|
} else if (swapSpec.style === 'beforeend') {
|
|
this.#insertNodes(target, null, fragment);
|
|
} else if (swapSpec.style === 'afterend') {
|
|
if (parentNode) {
|
|
this.#insertNodes(parentNode, target.nextSibling, fragment);
|
|
}
|
|
} else if (swapSpec.style === 'delete') {
|
|
if (parentNode) {
|
|
this.#cleanup(target)
|
|
parentNode.removeChild(target)
|
|
}
|
|
return;
|
|
} else if (swapSpec.style === 'none') {
|
|
return;
|
|
} else {
|
|
task.target = target;
|
|
task.fragment = fragment;
|
|
if (!this.#triggerExtensions(target, 'htmx:handle:swap', task)) return;
|
|
throw new Error(`Unknown swap style: ${swapSpec.style}`);
|
|
}
|
|
this.#restorePreservedElements(pantry);
|
|
for (const elt of newContent) {
|
|
this.process(elt);
|
|
this.#handleAutoFocus(elt);
|
|
}
|
|
this.#handleScroll(task);
|
|
}
|
|
|
|
#trigger(on, eventName, detail = {}, bubbles = true) {
|
|
if (this.config.logAll) {
|
|
console.log(eventName, detail, on)
|
|
}
|
|
on = this.#normalizeElement(on)
|
|
this.#triggerExtensions(on, this.#maybeAdjustMetaCharacter(eventName), detail);
|
|
return this.trigger(on, eventName, detail, bubbles)
|
|
}
|
|
|
|
#triggerExtensions(elt, eventName, detail = {}) {
|
|
let methods = this.#extMethods.get(eventName.replace(/:/g, '_'))
|
|
if (methods) {
|
|
detail.cancelled = false;
|
|
for (const fn of methods) {
|
|
if (fn(elt, detail) === false || detail.cancelled) {
|
|
detail.cancelled = true;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
timeout(time) {
|
|
if (typeof time === "string") {
|
|
time = this.parseInterval(time)
|
|
}
|
|
if (time > 0) {
|
|
return new Promise(resolve => setTimeout(resolve, time));
|
|
}
|
|
}
|
|
|
|
forEvent(event, timeout, on = document) {
|
|
return new Promise((resolve, reject) => {
|
|
let handler = (evt) => {
|
|
clearTimeout(timeoutId);
|
|
resolve(evt);
|
|
};
|
|
|
|
let timeoutId = timeout && setTimeout(() => {
|
|
on.removeEventListener(event, handler);
|
|
resolve(null);
|
|
}, timeout);
|
|
|
|
on.addEventListener(event, handler, { once: true });
|
|
})
|
|
}
|
|
|
|
onLoad(callback) {
|
|
this.on("htmx:after:init", (evt) => {
|
|
callback(evt.target)
|
|
})
|
|
}
|
|
|
|
takeClass(element, className, container = element.parentElement) {
|
|
for (let elt of this.findAll(this.#normalizeElement(container), "." + className)) {
|
|
elt.classList.remove(className);
|
|
}
|
|
element.classList.add(className);
|
|
}
|
|
|
|
on(eventOrElt, eventOrCallback, callback) {
|
|
let event;
|
|
let elt = document;
|
|
if (callback === undefined) {
|
|
event = eventOrElt;
|
|
callback = eventOrCallback
|
|
} else {
|
|
elt = this.#normalizeElement(eventOrElt);
|
|
event = eventOrCallback;
|
|
}
|
|
elt.addEventListener(event, callback);
|
|
return callback;
|
|
}
|
|
|
|
find(selectorOrElt, selector) {
|
|
return this.#findExt(selectorOrElt, selector)
|
|
}
|
|
|
|
findAll(selectorOrElt, selector) {
|
|
return this.#findAllExt(selectorOrElt, selector)
|
|
}
|
|
|
|
parseInterval(str) {
|
|
let m = {ms: 1, s: 1000, m: 60000};
|
|
let [, n, u] = str?.match(/^([\d.]+)(ms|s|m)?$/) || [];
|
|
let v = parseFloat(n) * (m[u] || 1);
|
|
return isNaN(v) ? undefined : v;
|
|
}
|
|
|
|
trigger(on, eventName, detail = {}, bubbles = true) {
|
|
on = this.#normalizeElement(on)
|
|
let evt = new CustomEvent(eventName, {
|
|
detail,
|
|
cancelable: true,
|
|
bubbles,
|
|
composed: true,
|
|
originalTarget: on
|
|
});
|
|
let target = on.isConnected ? on : document;
|
|
let result = !detail.cancelled && target.dispatchEvent(evt);
|
|
return result
|
|
}
|
|
// TODO - make async
|
|
ajax(verb, path, context) {
|
|
// Normalize context to object
|
|
if (!context || context instanceof Element || typeof context === 'string') {
|
|
context = {target: context};
|
|
}
|
|
|
|
let sourceElt = typeof context.source === 'string' ?
|
|
document.querySelector(context.source) : context.source;
|
|
|
|
// TODO we have a contradiction here: the tests say that we should default to the source element
|
|
// but the logic here targets the source element
|
|
let targetElt = context.target ?
|
|
this.#resolveTarget(sourceElt || document.body, context.target) : sourceElt;
|
|
|
|
if (!targetElt) {
|
|
return Promise.reject(new Error('Target not found'));
|
|
}
|
|
|
|
// TODO is this logic correct?
|
|
sourceElt ||= targetElt || document.body;
|
|
|
|
let ctx = this.#createRequestContext(sourceElt, context.event || {});
|
|
Object.assign(ctx, context, {target: targetElt});
|
|
Object.assign(ctx.request, {action: path, method: verb.toUpperCase()});
|
|
if (context.headers) Object.assign(ctx.request.headers, context.headers);
|
|
|
|
return this.#handleTriggerEvent(ctx);
|
|
}
|
|
|
|
//============================================================================================
|
|
// History Support
|
|
//============================================================================================
|
|
|
|
#initHistoryHandling() {
|
|
if (!this.config.history) return;
|
|
if (!history.state) {
|
|
history.replaceState({htmx: true}, '', location.pathname + location.search);
|
|
}
|
|
window.addEventListener('popstate', (event) => {
|
|
if (event.state && event.state.htmx) {
|
|
this.#restoreHistory();
|
|
}
|
|
});
|
|
}
|
|
|
|
#pushUrlIntoHistory(path) {
|
|
if (!this.config.history) return;
|
|
history.pushState({htmx: true}, '', path);
|
|
this.#trigger(document, "htmx:after:push:into:history", {path});
|
|
}
|
|
|
|
#replaceUrlInHistory(path) {
|
|
if (!this.config.history) return;
|
|
history.replaceState({htmx: true}, '', path);
|
|
this.#trigger(document, "htmx:after:replace:into:history", {path});
|
|
}
|
|
|
|
#restoreHistory(path) {
|
|
path = path || location.pathname + location.search;
|
|
if (this.#trigger(document, "htmx:before:restore:history", {path, cacheMiss: true})) {
|
|
if (this.config.historyReload) {
|
|
location.reload();
|
|
} else {
|
|
this.ajax('GET', path, {
|
|
target: 'body',
|
|
request: {headers: {'HX-History-Restore-Request': 'true'}}
|
|
});
|
|
}
|
|
} else if (elt.tagName === "FORM") {
|
|
return elt.method !== 'dialog' && this.#isSameOrigin(elt.action);
|
|
}
|
|
}
|
|
|
|
#handleHistoryUpdate(ctx) {
|
|
let {sourceElement, push, replace, hx, response} = ctx;
|
|
if (hx?.push || hx?.pushurl || hx?.replaceurl) {
|
|
push = hx.push || hx.pushurl;
|
|
replace = hx.replaceurl;
|
|
}
|
|
|
|
if (!push && !replace && this.#isBoosted(sourceElement)) {
|
|
push = 'true';
|
|
}
|
|
|
|
let path = push || replace;
|
|
if (!path || path === 'false') return;
|
|
|
|
if (path === 'true') {
|
|
path = ctx.request.originalAction;
|
|
}
|
|
|
|
let type = push ? 'push' : 'replace';
|
|
|
|
let historyDetail = {
|
|
history: {type, path},
|
|
sourceElement,
|
|
response
|
|
};
|
|
if (!this.#trigger(document, "htmx:before:history:update", historyDetail)) return;
|
|
if (type === 'push') {
|
|
this.#pushUrlIntoHistory(path);
|
|
} else {
|
|
this.#replaceUrlInHistory(path);
|
|
}
|
|
this.#trigger(document, "htmx:after:history:update", historyDetail);
|
|
}
|
|
|
|
#handleHxOnAttributes(node) {
|
|
for (let attr of node.getAttributeNames()) {
|
|
var searchString = this.#maybeAdjustMetaCharacter(this.#prefix("hx-on:"));
|
|
if (attr.startsWith(searchString)) {
|
|
let evtName = attr.substring(searchString.length)
|
|
let code = node.getAttribute(attr);
|
|
node.addEventListener(evtName, async (evt) => {
|
|
try {
|
|
await this.#executeJavaScriptAsync(node, {"event": evt}, code, false)
|
|
} catch (e) {
|
|
console.log(e);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
#showIndicators(elt, indicatorsSelector) {
|
|
let indicatorElements = []
|
|
if (indicatorsSelector) {
|
|
indicatorElements = [elt, ...this.#queryEltAndDescendants(elt, indicatorsSelector)];
|
|
for (const indicator of indicatorElements) {
|
|
indicator._htmxReqCount ||= 0
|
|
indicator._htmxReqCount++
|
|
indicator.classList.add(this.config.requestClass)
|
|
}
|
|
}
|
|
return indicatorElements
|
|
}
|
|
|
|
#hideIndicators(indicatorElements) {
|
|
for (let indicator of indicatorElements) {
|
|
if (indicator._htmxReqCount) {
|
|
indicator._htmxReqCount--;
|
|
if (indicator._htmxReqCount <= 0) {
|
|
indicator.classList.remove(this.config.requestClass);
|
|
delete indicator._htmxReqCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#disableElements(elt, disabledSelector) {
|
|
let disabledElements = []
|
|
if (disabledSelector) {
|
|
disabledElements = this.#queryEltAndDescendants(elt, disabledSelector);
|
|
for (let indicator of disabledElements) {
|
|
indicator._htmxDisableCount ||= 0
|
|
indicator._htmxDisableCount++
|
|
indicator.disabled = true
|
|
}
|
|
}
|
|
return disabledElements
|
|
}
|
|
|
|
#enableElements(disabledElements) {
|
|
for (const indicator of disabledElements) {
|
|
if (indicator._htmxDisableCount) {
|
|
indicator._htmxDisableCount--
|
|
if (indicator._htmxDisableCount <= 0) {
|
|
indicator.disabled = false
|
|
delete indicator._htmxDisableCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#collectFormData(elt, form, submitter) {
|
|
let formData = new FormData()
|
|
let included = new Set()
|
|
if (form) {
|
|
this.#addInputValues(form, included, formData)
|
|
} else if (elt.name) {
|
|
formData.append(elt.name, elt.value)
|
|
included.add(elt);
|
|
}
|
|
if (submitter && submitter.name) {
|
|
formData.append(submitter.name, submitter.value)
|
|
included.add(submitter);
|
|
}
|
|
let includeSelector = this.#attributeValue(elt, "hx-include");
|
|
if (includeSelector) {
|
|
let includeNodes = this.#findAllExt(elt, includeSelector);
|
|
for (let node of includeNodes) {
|
|
this.#addInputValues(node, included, formData);
|
|
}
|
|
}
|
|
return formData
|
|
}
|
|
|
|
#addInputValues(elt, included, formData) {
|
|
let inputs = this.#queryEltAndDescendants(elt, 'input:not([disabled]), select:not([disabled]), textarea:not([disabled])');
|
|
|
|
for (let input of inputs) {
|
|
// Skip elements without a name or already seen
|
|
if (!input.name || included.has(input)) continue;
|
|
included.add(input);
|
|
|
|
if (input.matches('input[type=checkbox], input[type=radio]')) {
|
|
// Only add if checked
|
|
if (input.checked) {
|
|
formData.append(input.name, input.value);
|
|
}
|
|
} else if (input.matches('input[type=file]')) {
|
|
// Add all selected files
|
|
for (let file of input.files) {
|
|
formData.append(input.name, file);
|
|
}
|
|
} else if (input.matches('select[multiple]')) {
|
|
// Add all selected options
|
|
for (let option of input.selectedOptions) {
|
|
formData.append(input.name, option.value);
|
|
}
|
|
} else if (input.matches('select, textarea, input')) {
|
|
// Regular inputs, single selects, textareas
|
|
formData.append(input.name, input.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
#handleHxVals(elt, body) {
|
|
let hxValsValue = this.#attributeValue(elt, "hx-vals");
|
|
if (hxValsValue) {
|
|
if (!hxValsValue.includes('{')) {
|
|
hxValsValue = `{${hxValsValue}}`
|
|
}
|
|
let obj = JSON.parse(hxValsValue);
|
|
for (let key in obj) {
|
|
body.append(key, obj[key])
|
|
}
|
|
}
|
|
}
|
|
|
|
#stringHyperscriptStyleSelector(selector) {
|
|
let s = selector.trim();
|
|
return s.startsWith('<') && s.endsWith('/>') ? s.slice(1, -2) : s;
|
|
}
|
|
|
|
#findAllExt(eltOrSelector, maybeSelector, global) {
|
|
let [elt, selector] = this.#normalizeElementAndSelector(eltOrSelector, maybeSelector)
|
|
if (selector.startsWith('global ')) {
|
|
return this.#findAllExt(elt, selector.slice(7), true);
|
|
}
|
|
let parts = this.#tokenizeExtendedSelector(selector);
|
|
let result = []
|
|
let unprocessedParts = []
|
|
for (const part of parts) {
|
|
let selector = this.#stringHyperscriptStyleSelector(part)
|
|
let item
|
|
if (selector.startsWith('closest ')) {
|
|
item = elt.closest(selector.slice(8))
|
|
} else if (selector.startsWith('find ')) {
|
|
item = document.querySelector(elt, selector.slice(5))
|
|
} else if (selector === 'next' || selector === 'nextElementSibling') {
|
|
item = elt.nextElementSibling
|
|
} else if (selector.startsWith('next ')) {
|
|
item = this.#scanForwardQuery(elt, selector.slice(5), !!global)
|
|
} else if (selector === 'previous' || selector === 'previousElementSibling') {
|
|
item = elt.previousElementSibling
|
|
} else if (selector.startsWith('previous ')) {
|
|
item = this.#scanBackwardsQuery(elt, selector.slice(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 = this.#getRootNode(elt, !!global)
|
|
} else if (selector === 'host') {
|
|
item = (elt.getRootNode()).host
|
|
} else {
|
|
unprocessedParts.push(selector)
|
|
}
|
|
|
|
if (item) {
|
|
result.push(item)
|
|
}
|
|
}
|
|
|
|
if (unprocessedParts.length > 0) {
|
|
let standardSelector = unprocessedParts.join(',')
|
|
let rootNode = this.#getRootNode(elt, !!global)
|
|
result.push(...rootNode.querySelectorAll(standardSelector))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
#normalizeElementAndSelector(eltOrSelector, selector) {
|
|
if (selector === undefined) {
|
|
return [document, eltOrSelector];
|
|
} else {
|
|
return [this.#normalizeElement(eltOrSelector), selector];
|
|
}
|
|
}
|
|
|
|
#tokenizeExtendedSelector(selector) {
|
|
let parts = [], depth = 0, start = 0;
|
|
for (let i = 0; i <= selector.length; i++) {
|
|
let c = selector[i];
|
|
if (c === '<') depth++;
|
|
else if (c === '/' && selector[i + 1] === '>') depth--;
|
|
else if ((c === ',' && !depth) || i === selector.length) {
|
|
if (i > start) parts.push(selector.substring(start, i));
|
|
start = i + 1;
|
|
}
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
#scanForwardQuery(start, match, global) {
|
|
return this.#scanUntilComparison(this.#getRootNode(start, global).querySelectorAll(match), start, Node.DOCUMENT_POSITION_PRECEDING);
|
|
}
|
|
|
|
#scanBackwardsQuery(start, match, global) {
|
|
let results = [...this.#getRootNode(start, global).querySelectorAll(match)].reverse()
|
|
return this.#scanUntilComparison(results, start, Node.DOCUMENT_POSITION_FOLLOWING);
|
|
}
|
|
|
|
#scanUntilComparison(results, start, comparison) {
|
|
for (const elt of results) {
|
|
if (elt.compareDocumentPosition(start) === comparison) {
|
|
return elt
|
|
}
|
|
}
|
|
}
|
|
|
|
#getRootNode(elt, global) {
|
|
if (elt.isConnected && elt.getRootNode) {
|
|
return elt.getRootNode?.({composed: global})
|
|
} else {
|
|
return document
|
|
}
|
|
}
|
|
|
|
#findExt(eltOrSelector, selector) {
|
|
return this.#findAllExt(eltOrSelector, selector)[0]
|
|
}
|
|
|
|
#extractJavascriptContent(string) {
|
|
if (string != null) {
|
|
if (string.startsWith("js:")) {
|
|
return string.substring(3);
|
|
} else if (string.startsWith("javascript:")) {
|
|
return string.substring(11);
|
|
}
|
|
}
|
|
}
|
|
|
|
#initializeAbortListener(elt) {
|
|
elt.addEventListener("htmx:abort", () => {
|
|
let requestQueue = this.#getRequestQueue(elt);
|
|
requestQueue.abort();
|
|
})
|
|
}
|
|
|
|
#morph(oldNode, fragment, innerHTML) {
|
|
let {persistentIds, idMap} = this.#createIdMaps(oldNode, fragment);
|
|
let pantry = document.createElement("div");
|
|
pantry.hidden = true;
|
|
document.body.after( pantry);
|
|
let ctx = {target: oldNode, idMap, persistentIds, pantry};
|
|
|
|
if (innerHTML) {
|
|
this.#morphChildren(ctx, oldNode, fragment);
|
|
} else {
|
|
this.#morphChildren(ctx, oldNode.parentNode, fragment, oldNode, oldNode.nextSibling);
|
|
}
|
|
this.#cleanup(pantry)
|
|
pantry.remove();
|
|
}
|
|
|
|
#morphChildren(ctx, oldParent, newParent, insertionPoint = null, endPoint = null) {
|
|
if (oldParent instanceof HTMLTemplateElement && newParent instanceof HTMLTemplateElement) {
|
|
oldParent = oldParent.content;
|
|
newParent = newParent.content;
|
|
}
|
|
insertionPoint ||= oldParent.firstChild;
|
|
|
|
for (const newChild of newParent.childNodes) {
|
|
if (insertionPoint && insertionPoint != endPoint) {
|
|
let bestMatch = this.#findBestMatch(ctx, newChild, insertionPoint, endPoint);
|
|
if (bestMatch) {
|
|
if (bestMatch !== insertionPoint) {
|
|
let cursor = insertionPoint;
|
|
while (cursor && cursor !== bestMatch) {
|
|
let tempNode = cursor;
|
|
cursor = cursor.nextSibling;
|
|
this.#removeNode(ctx, tempNode);
|
|
}
|
|
}
|
|
this.#morphNode(bestMatch, newChild, ctx);
|
|
insertionPoint = bestMatch.nextSibling;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
|
|
let target = (ctx.target.id === newChild.id && ctx.target) ||
|
|
ctx.target.querySelector(`[id="${newChild.id}"]`) ||
|
|
ctx.pantry.querySelector(`[id="${newChild.id}"]`);
|
|
let elementId = target.id;
|
|
let element = target;
|
|
while ((element = element.parentNode)) {
|
|
let idSet = ctx.idMap.get(element);
|
|
if (idSet) {
|
|
idSet.delete(elementId);
|
|
if (!idSet.size) ctx.idMap.delete(element);
|
|
}
|
|
}
|
|
this.#moveBefore(oldParent, target, insertionPoint);
|
|
this.#morphNode(target, newChild, ctx);
|
|
insertionPoint = target.nextSibling;
|
|
continue;
|
|
}
|
|
|
|
let tempChild;
|
|
if (ctx.idMap.has(newChild)) {
|
|
tempChild = document.createElement(newChild.tagName);
|
|
oldParent.insertBefore(tempChild, insertionPoint);
|
|
this.#morphNode(tempChild, newChild, ctx);
|
|
} else {
|
|
tempChild = document.importNode(newChild, true);
|
|
oldParent.insertBefore(tempChild, insertionPoint);
|
|
}
|
|
insertionPoint = tempChild.nextSibling;
|
|
}
|
|
|
|
while (insertionPoint && insertionPoint != endPoint) {
|
|
let tempNode = insertionPoint;
|
|
insertionPoint = insertionPoint.nextSibling;
|
|
this.#removeNode(ctx, tempNode);
|
|
}
|
|
}
|
|
|
|
#findBestMatch(ctx, node, startPoint, endPoint) {
|
|
let softMatch = null, nextSibling = node.nextSibling, siblingSoftMatchCount = 0, displaceMatchCount = 0;
|
|
let newSet = ctx.idMap.get(node), nodeMatchCount = newSet?.size || 0;
|
|
let cursor = startPoint;
|
|
while (cursor && cursor != endPoint) {
|
|
let oldSet = ctx.idMap.get(cursor);
|
|
if (this.#isSoftMatch(cursor, node)) {
|
|
if (oldSet && newSet && [...oldSet].some(id => newSet.has(id))) return cursor;
|
|
if (softMatch === null && !oldSet) {
|
|
if (!nodeMatchCount) return cursor;
|
|
else softMatch = cursor;
|
|
}
|
|
}
|
|
displaceMatchCount += oldSet?.size || 0;
|
|
if (displaceMatchCount > nodeMatchCount) break;
|
|
if (softMatch === null && nextSibling && this.#isSoftMatch(cursor, nextSibling)) {
|
|
siblingSoftMatchCount++;
|
|
nextSibling = nextSibling.nextSibling;
|
|
if (siblingSoftMatchCount >= 2) softMatch = undefined;
|
|
}
|
|
if (cursor.contains(document.activeElement)) break;
|
|
cursor = cursor.nextSibling;
|
|
}
|
|
return softMatch || null;
|
|
}
|
|
|
|
#isSoftMatch(oldNode, newNode) {
|
|
return oldNode.nodeType === newNode.nodeType && oldNode.tagName === newNode.tagName &&
|
|
(!oldNode.id || oldNode.id === newNode.id);
|
|
}
|
|
|
|
#removeNode(ctx, node) {
|
|
if (ctx.idMap.has(node)) {
|
|
this.#moveBefore(ctx.pantry, node, null);
|
|
} else {
|
|
this.#cleanup(node)
|
|
node.remove();
|
|
}
|
|
}
|
|
|
|
#moveBefore(parentNode, element, after) {
|
|
if (parentNode.moveBefore) {
|
|
try {
|
|
parentNode.moveBefore(element, after);
|
|
return
|
|
} catch (e) {
|
|
// ignore and insertBefore insteat
|
|
}
|
|
}
|
|
parentNode.insertBefore(element, after);
|
|
}
|
|
|
|
#morphNode(oldNode, newNode, ctx) {
|
|
let type = newNode.nodeType;
|
|
|
|
if (type === 1) {
|
|
let noMorph = this.config.morphIgnore || [];
|
|
this.#copyAttributes(oldNode, newNode, noMorph);
|
|
if (oldNode instanceof HTMLTextAreaElement && oldNode.defaultValue != newNode.defaultValue) {
|
|
oldNode.value = newNode.value;
|
|
}
|
|
}
|
|
|
|
if ((type === 8 || type === 3) && oldNode.nodeValue !== newNode.nodeValue) {
|
|
oldNode.nodeValue = newNode.nodeValue;
|
|
}
|
|
if (!oldNode.isEqualNode(newNode)) this.#morphChildren(ctx, oldNode, newNode);
|
|
}
|
|
|
|
#copyAttributes(destination, source, attributesToIgnore = []) {
|
|
for (const attr of source.attributes) {
|
|
if (!attributesToIgnore.includes(attr.name) && destination.getAttribute(attr.name) !== attr.value) {
|
|
destination.setAttribute(attr.name, attr.value);
|
|
if (attr.name === "value" && destination instanceof HTMLInputElement && destination.type !== "file") {
|
|
destination.value = attr.value;
|
|
}
|
|
}
|
|
}
|
|
for (let i = destination.attributes.length - 1; i >= 0; i--) {
|
|
let attr = destination.attributes[i];
|
|
if (attr && !source.hasAttribute(attr.name) && !attributesToIgnore.includes(attr.name)) {
|
|
destination.removeAttribute(attr.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
#populateIdMapWithTree(idMap, persistentIds, root, elements) {
|
|
for (const elt of elements) {
|
|
if (persistentIds.has(elt.id)) {
|
|
let current = elt;
|
|
while (current && current !== root) {
|
|
let idSet = idMap.get(current);
|
|
if (idSet == null) {
|
|
idSet = new Set();
|
|
idMap.set(current, idSet);
|
|
}
|
|
idSet.add(elt.id);
|
|
current = current.parentElement;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#createIdMaps(oldNode, newContent) {
|
|
let oldIdElements = this.#queryEltAndDescendants(oldNode, "[id]");
|
|
let newIdElements = newContent.querySelectorAll("[id]");
|
|
let persistentIds = this.#createPersistentIds(oldIdElements, newIdElements);
|
|
let idMap = new Map();
|
|
this.#populateIdMapWithTree(idMap, persistentIds, oldNode.parentElement, oldIdElements);
|
|
this.#populateIdMapWithTree(idMap, persistentIds, newContent, newIdElements);
|
|
return {persistentIds, idMap};
|
|
}
|
|
|
|
#createPersistentIds(oldIdElements, newIdElements) {
|
|
let duplicateIds = new Set(), oldIdTagNameMap = new Map();
|
|
for (const {id, tagName} of oldIdElements) {
|
|
if (oldIdTagNameMap.has(id)) duplicateIds.add(id);
|
|
else oldIdTagNameMap.set(id, tagName);
|
|
}
|
|
let persistentIds = new Set();
|
|
for (const {id, tagName} of newIdElements) {
|
|
if (persistentIds.has(id)) duplicateIds.add(id);
|
|
else if (oldIdTagNameMap.get(id) === tagName) persistentIds.add(id);
|
|
}
|
|
for (const id of duplicateIds) persistentIds.delete(id);
|
|
return persistentIds;
|
|
}
|
|
|
|
#handleStatusCodes(ctx) {
|
|
let status = ctx.response.raw.status;
|
|
let noSwapStrings = this.config.noSwap.map(x => x + "");
|
|
let str = status + ""
|
|
for (let pattern of [str, str.slice(0, 2) + 'x', str[0] + 'xx']) {
|
|
let swap = this.#attributeValue(ctx.sourceElement, "hx-status:" + pattern);
|
|
if (noSwapStrings.includes(pattern)) {
|
|
ctx.swap = "none";
|
|
return
|
|
}
|
|
if (swap) {
|
|
ctx.swap = swap;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#submitTransitionTask(task) {
|
|
return new Promise((resolve) => {
|
|
this.#transitionQueue ||= [];
|
|
this.#transitionQueue.push({ task, resolve });
|
|
if (!this.#processingTransition) {
|
|
this.#processTransitionQueue();
|
|
}
|
|
});
|
|
}
|
|
|
|
async #processTransitionQueue() {
|
|
if (this.#transitionQueue.length === 0 || this.#processingTransition) {
|
|
return;
|
|
}
|
|
|
|
this.#processingTransition = true;
|
|
let { task, resolve } = this.#transitionQueue.shift();
|
|
|
|
try {
|
|
if (document.startViewTransition) {
|
|
this.#trigger(document, "htmx:before:viewTransition", {task})
|
|
await document.startViewTransition(task).finished;
|
|
this.#trigger(document, "htmx:after:viewTransition", {task})
|
|
} else {
|
|
task();
|
|
}
|
|
} catch (e) {
|
|
// Transitions can be skipped/aborted - this is normal
|
|
} finally {
|
|
this.#processingTransition = false;
|
|
resolve();
|
|
this.#processTransitionQueue();
|
|
}
|
|
}
|
|
|
|
#captureCSSTransitions(task, root) {
|
|
let idElements = root.querySelectorAll("[id]");
|
|
let existingElementsById = Object.fromEntries([...idElements].map(e => [e.id, e]));
|
|
let newElementsWithIds = task.fragment.querySelectorAll("[id]");
|
|
task.restoreTasks = []
|
|
for (let elt of newElementsWithIds) {
|
|
let existing = existingElementsById[elt.id];
|
|
if (existing?.tagName === elt.tagName) {
|
|
let clone = elt.cloneNode(false); // shallow clone node
|
|
this.#copyAttributes(elt, existing, this.config.morphIgnore)
|
|
task.restoreTasks.push(()=>{
|
|
this.#copyAttributes(elt, clone, this.config.morphIgnore)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
#normalizeElement(cssOrElement) {
|
|
if (typeof cssOrElement === "string") {
|
|
return this.find(cssOrElement);
|
|
} else {
|
|
return cssOrElement
|
|
}
|
|
}
|
|
|
|
#maybeAdjustMetaCharacter(string) {
|
|
if (this.config.metaCharacter) {
|
|
return string.replace(/:/g, this.config.metaCharacter);
|
|
} else {
|
|
return string;
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Htmx()
|
|
})()
|