prep web for release

This commit is contained in:
Carson Gross 2023-12-21 17:38:56 -07:00
parent b1e15c08cc
commit 6489f1bfef
19 changed files with 1268 additions and 480 deletions

View File

@ -0,0 +1,11 @@
htmx.defineExtension('path-params', {
onEvent: function(name, evt) {
if (name === "htmx:configRequest") {
evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function (_, param) {
var val = evt.detail.parameters[param];
delete evt.detail.parameters[param];
return val === undefined ? "{" + param + "}" : encodeURIComponent(val);
})
}
}
});

View File

@ -5,7 +5,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
*/
(function(){
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api;
@ -39,17 +39,19 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
switch (name) {
// Try to remove remove an EventSource when elements are removed
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(evt.target)
if (internalData.sseEventSource) {
internalData.sseEventSource.close();
}
return;
case "htmx:beforeCleanupElement":
var internalData = api.getInternalData(evt.target)
// Try to remove remove an EventSource when elements are removed
if (internalData.sseEventSource) {
internalData.sseEventSource.close();
}
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
createEventSourceOnElement(evt.target);
return;
// Try to create EventSources when elements are processed
case "htmx:afterProcessNode":
ensureEventSourceOnElement(evt.target);
registerSSE(evt.target);
}
}
});
@ -66,8 +68,8 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, {withCredentials:true});
function createEventSource(url) {
return new EventSource(url, { withCredentials: true });
}
function splitOnWhitespace(trigger) {
@ -90,7 +92,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
function getLegacySSESwaps(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
var returnArr = [];
if (legacySSEValue) {
if (legacySSEValue != null) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
@ -103,63 +105,24 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
}
/**
* createEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function createEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null;
function registerSSE(elt) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource);
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null; // no eventsource in parentage, orphaned element
}
var internalData = api.getInternalData(elt);
// Set internalData and source
var internalData = api.getInternalData(sourceElement);
var source = internalData.sseEventSource;
// get URL from element's attribute
var sseURL = api.getAttributeValue(elt, "sse-connect");
if (sseURL == undefined) {
var legacyURL = getLegacySSEURL(elt)
if (legacyURL) {
sseURL = legacyURL;
} else {
return null;
}
}
// Connect to the EventSource
var source = htmx.createEventSource(sseURL);
internalData.sseEventSource = source;
// Create event handlers
source.onerror = function (err) {
// Log an error event
api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source:source});
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return;
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0;
var timeout = Math.random() * (2 ^ retryCount) * 500;
window.setTimeout(function() {
createEventSourceOnElement(elt, Math.min(7, retryCount+1));
}, timeout);
}
};
source.onopen = function (evt) {
api.triggerEvent(elt, "htmx:sseOpen", {source: source});
}
// Add message handlers for every `sse-swap` attribute
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
@ -170,23 +133,27 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
var sseEventNames = getLegacySSESwaps(child);
}
for (var i = 0 ; i < sseEventNames.length ; i++) {
for (var i = 0; i < sseEventNames.length; i++) {
var sseEventName = sseEventNames[i].trim();
var listener = function(event) {
// If the parent is missing then close SSE and remove listener
if (maybeCloseSSESource(elt)) {
source.removeEventListener(sseEventName, listener);
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return;
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
// swap the response into the DOM and trigger a notification
swap(child, event.data);
api.triggerEvent(elt, "htmx:sseMessage", event);
};
// Register the new listener
api.getInternalData(elt).sseEventListener = listener;
api.getInternalData(child).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
}
});
@ -203,24 +170,86 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
if (sseEventName.slice(0, 4) != "sse:") {
return;
}
// remove the sse: prefix from here on out
sseEventName = sseEventName.substr(4);
var listener = function(event) {
// If parent is missing, then close SSE and remove listener
if (maybeCloseSSESource(elt)) {
source.removeEventListener(sseEventName, listener);
return;
var listener = function() {
if (maybeCloseSSESource(sourceElement)) {
return
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(child, sseEventName, event);
htmx.trigger(child, "htmx:sseMessage", event);
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
}
});
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null;
}
// handle extension source creation attribute
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
var sseURL = api.getAttributeValue(child, "sse-connect");
if (sseURL == null) {
return;
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener;
source.addEventListener(sseEventName.slice(4), listener);
ensureEventSource(child, sseURL, retryCount);
});
// handle legacy sse, remove for HTMX2
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
var sseURL = getLegacySSEURL(child);
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url);
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return;
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0;
var timeout = Math.random() * (2 ^ retryCount) * 500;
window.setTimeout(function() {
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
}, timeout);
}
};
source.onopen = function(evt) {
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
}
api.getInternalData(elt).sseEventSource = source;
}
/**
@ -253,12 +282,12 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
var result = [];
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) {
if (api.hasAttribute(elt, attributeName)) {
result.push(elt);
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) {
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
result.push(node);
});
@ -281,7 +310,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
settleInfo.elts.forEach(function (elt) {
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.add(htmx.config.settlingClass);
}
@ -306,11 +335,11 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
function doSettle(settleInfo) {
return function() {
settleInfo.tasks.forEach(function (task) {
settleInfo.tasks.forEach(function(task) {
task.call();
});
settleInfo.elts.forEach(function (elt) {
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass);
}
@ -319,4 +348,8 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
}
}
})();
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null;
}
})();

View File

@ -400,6 +400,42 @@ export interface HtmxConfig {
* @default true
*/
scrollIntoViewOnBoost?: boolean;
/**
* If set, the nonce will be added to inline scripts.
* @default ''
*/
inlineScriptNonce?: string;
/**
* The type of binary data being received over the WebSocket connection
* @default 'blob'
*/
wsBinaryType?: 'blob' | 'arraybuffer';
/**
* If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser
* @default false
*/
getCacheBusterParam?: boolean;
/**
* If set to true, htmx will use the View Transition API when swapping in new content.
* @default false
*/
globalViewTransitions?: boolean;
/**
* htmx will format requests with these methods by encoding their parameters in the URL, not the request body
* @default ["get"]
*/
methodsThatUseUrlParams?: ('get' | 'head' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' | 'patch' )[];
/**
* If set to true htmx will not update the title of the document when a title tag is found in new content
* @default false
*/
ignoreTitle:? boolean;
/**
* 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)
* @default null
*/
triggerSpecsCache?: {[trigger: string]: HtmxTriggerSpecification[]};
}
/**

View File

@ -76,7 +76,8 @@ return (function () {
methodsThatUseUrlParams: ["get"],
selfRequestsOnly: false,
ignoreTitle: false,
scrollIntoViewOnBoost: true
scrollIntoViewOnBoost: true,
triggerSpecsCache: null,
},
parseInterval:parseInterval,
_:internalEval,
@ -127,24 +128,40 @@ return (function () {
return "[hx-" + verb + "], [data-hx-" + verb + "]"
}).join(", ");
var HEAD_TAG_REGEX = makeTagRegEx('head'),
TITLE_TAG_REGEX = makeTagRegEx('title'),
SVG_TAGS_REGEX = makeTagRegEx('svg', true);
//====================================================================
// Utilities
//====================================================================
/**
* @param {string} tag
* @param {boolean} global
* @returns {RegExp}
*/
function makeTagRegEx(tag, global = false) {
return new RegExp(`<${tag}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${tag}>`,
global ? 'gim' : 'im');
}
function parseInterval(str) {
if (str == undefined) {
return undefined
return undefined;
}
let interval = NaN;
if (str.slice(-2) == "ms") {
return parseFloat(str.slice(0,-2)) || undefined
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);
}
if (str.slice(-1) == "s") {
return (parseFloat(str.slice(0,-1)) * 1000) || undefined
}
if (str.slice(-1) == "m") {
return (parseFloat(str.slice(0,-1)) * 1000 * 60) || undefined
}
return parseFloat(str) || undefined
return isNaN(interval) ? undefined : interval;
}
/**
@ -276,43 +293,46 @@ return (function () {
}
function aFullPageResponse(resp) {
return resp.match(/<body/);
return /<body/.test(resp)
}
/**
*
* @param {string} resp
* @param {string} response
* @returns {Element}
*/
function makeFragment(resp) {
var partialResponse = !aFullPageResponse(resp);
function makeFragment(response) {
var partialResponse = !aFullPageResponse(response);
var startTag = getStartTag(response);
var content = response;
if (startTag === 'head') {
content = content.replace(HEAD_TAG_REGEX, '');
}
if (htmx.config.useTemplateFragments && partialResponse) {
var documentFragment = parseHTML("<body><template>" + resp + "</template></body>", 0);
var documentFragment = parseHTML("<body><template>" + content + "</template></body>", 0);
// @ts-ignore type mismatch between DocumentFragment and Element.
// TODO: Are these close enough for htmx to use interchangeably?
return documentFragment.querySelector('template').content;
} else {
var startTag = getStartTag(resp);
switch (startTag) {
case "thead":
case "tbody":
case "tfoot":
case "colgroup":
case "caption":
return parseHTML("<table>" + resp + "</table>", 1);
case "col":
return parseHTML("<table><colgroup>" + resp + "</colgroup></table>", 2);
case "tr":
return parseHTML("<table><tbody>" + resp + "</tbody></table>", 2);
case "td":
case "th":
return parseHTML("<table><tbody><tr>" + resp + "</tr></tbody></table>", 3);
case "script":
case "style":
return parseHTML("<div>" + resp + "</div>", 1);
default:
return parseHTML(resp, 0);
}
}
switch (startTag) {
case "thead":
case "tbody":
case "tfoot":
case "colgroup":
case "caption":
return parseHTML("<table>" + content + "</table>", 1);
case "col":
return parseHTML("<table><colgroup>" + content + "</colgroup></table>", 2);
case "tr":
return parseHTML("<table><tbody>" + content + "</tbody></table>", 2);
case "td":
case "th":
return parseHTML("<table><tbody><tr>" + content + "</tr></tbody></table>", 3);
case "script":
case "style":
return parseHTML("<div>" + content + "</div>", 1);
default:
return parseHTML(content, 0);
}
}
@ -450,7 +470,7 @@ return (function () {
path = url.pathname + url.search;
}
// remove trailing slash, unless index page
if (!path.match('^/$')) {
if (!(/^\/$/.test(path))) {
path = path.replace(/\/+$/, '');
}
return path;
@ -827,7 +847,7 @@ return (function () {
var oobSelects = getClosestAttributeValue(elt, "hx-select-oob");
if (oobSelects) {
var oobSelectValues = oobSelects.split(",");
for (let i = 0; i < oobSelectValues.length; i++) {
for (var i = 0; i < oobSelectValues.length; i++) {
var oobSelectValue = oobSelectValues[i].split(":", 2);
var id = oobSelectValue[0].trim();
if (id.indexOf("#") === 0) {
@ -934,7 +954,7 @@ return (function () {
function deInitOnHandlers(elt) {
var internalData = getInternalData(elt);
if (internalData.onHandlers) {
for (let i = 0; i < internalData.onHandlers.length; i++) {
for (var i = 0; i < internalData.onHandlers.length; i++) {
const handlerInfo = internalData.onHandlers[i];
elt.removeEventListener(handlerInfo.event, handlerInfo.listener);
}
@ -960,10 +980,8 @@ return (function () {
}
});
}
if (internalData.initHash) {
internalData.initHash = null
}
deInitOnHandlers(element);
forEach(Object.keys(internalData), function(key) { delete internalData[key] });
}
function cleanUpElement(element) {
@ -987,7 +1005,6 @@ return (function () {
} else {
newElt = eltBeforeNewContent.nextSibling;
}
getInternalData(target).replacedWith = newElt; // tuck away so we can fire events on it later
settleInfo.elts = settleInfo.elts.filter(function(e) { return e != target });
while(newElt && newElt !== target) {
if (newElt.nodeType === Node.ELEMENT_NODE) {
@ -1099,9 +1116,8 @@ return (function () {
function findTitle(content) {
if (content.indexOf('<title') > -1) {
var contentWithSvgsRemoved = content.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
var result = contentWithSvgsRemoved.match(/<title(\s[^>]*>|>)([\s\S]*?)<\/title>/im);
var contentWithSvgsRemoved = content.replace(SVG_TAGS_REGEX, '');
var result = contentWithSvgsRemoved.match(TITLE_TAG_REGEX);
if (result) {
return result[2];
}
@ -1230,7 +1246,7 @@ return (function () {
function consumeUntil(tokens, match) {
var result = "";
while (tokens.length > 0 && !tokens[0].match(match)) {
while (tokens.length > 0 && !match.test(tokens[0])) {
result += tokens.shift();
}
return result;
@ -1250,6 +1266,99 @@ return (function () {
var INPUT_SELECTOR = 'input, textarea, select';
/**
* @param {HTMLElement} elt
* @param {string} explicitTrigger
* @param {cache} cache for trigger specs
* @returns {import("./htmx").HtmxTriggerSpecification[]}
*/
function parseAndCacheTrigger(elt, explicitTrigger, cache) {
var triggerSpecs = [];
var tokens = tokenizeString(explicitTrigger);
do {
consumeUntil(tokens, NOT_WHITESPACE);
var initialLength = tokens.length;
var trigger = consumeUntil(tokens, /[,\[\s]/);
if (trigger !== "") {
if (trigger === "every") {
var every = {trigger: 'every'};
consumeUntil(tokens, NOT_WHITESPACE);
every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/));
consumeUntil(tokens, NOT_WHITESPACE);
var eventFilter = maybeGenerateConditional(elt, tokens, "event");
if (eventFilter) {
every.eventFilter = eventFilter;
}
triggerSpecs.push(every);
} else if (trigger.indexOf("sse:") === 0) {
triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)});
} else {
var triggerSpec = {trigger: trigger};
var eventFilter = maybeGenerateConditional(elt, tokens, "event");
if (eventFilter) {
triggerSpec.eventFilter = eventFilter;
}
while (tokens.length > 0 && tokens[0] !== ",") {
consumeUntil(tokens, NOT_WHITESPACE)
var token = tokens.shift();
if (token === "changed") {
triggerSpec.changed = true;
} else if (token === "once") {
triggerSpec.once = true;
} else if (token === "consume") {
triggerSpec.consume = true;
} else if (token === "delay" && tokens[0] === ":") {
tokens.shift();
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
if (COMBINED_SELECTOR_START.test(tokens[0])) {
var from_arg = consumeCSSSelector(tokens);
} else {
var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA);
if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") {
tokens.shift();
var selector = consumeCSSSelector(tokens);
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
}
}
}
triggerSpec.from = from_arg;
} else if (token === "target" && tokens[0] === ":") {
tokens.shift();
triggerSpec.target = consumeCSSSelector(tokens);
} else if (token === "throttle" && tokens[0] === ":") {
tokens.shift();
triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "queue" && tokens[0] === ":") {
tokens.shift();
triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else if (token === "root" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeCSSSelector(tokens);
} else if (token === "threshold" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else {
triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()});
}
}
triggerSpecs.push(triggerSpec);
}
}
if (tokens.length === initialLength) {
triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()});
}
consumeUntil(tokens, NOT_WHITESPACE);
} while (tokens[0] === "," && tokens.shift())
if (cache) {
cache[explicitTrigger] = triggerSpecs
}
return triggerSpecs
}
/**
* @param {HTMLElement} elt
* @returns {import("./htmx").HtmxTriggerSpecification[]}
@ -1258,85 +1367,8 @@ return (function () {
var explicitTrigger = getAttributeValue(elt, 'hx-trigger');
var triggerSpecs = [];
if (explicitTrigger) {
var tokens = tokenizeString(explicitTrigger);
do {
consumeUntil(tokens, NOT_WHITESPACE);
var initialLength = tokens.length;
var trigger = consumeUntil(tokens, /[,\[\s]/);
if (trigger !== "") {
if (trigger === "every") {
var every = {trigger: 'every'};
consumeUntil(tokens, NOT_WHITESPACE);
every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/));
consumeUntil(tokens, NOT_WHITESPACE);
var eventFilter = maybeGenerateConditional(elt, tokens, "event");
if (eventFilter) {
every.eventFilter = eventFilter;
}
triggerSpecs.push(every);
} else if (trigger.indexOf("sse:") === 0) {
triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)});
} else {
var triggerSpec = {trigger: trigger};
var eventFilter = maybeGenerateConditional(elt, tokens, "event");
if (eventFilter) {
triggerSpec.eventFilter = eventFilter;
}
while (tokens.length > 0 && tokens[0] !== ",") {
consumeUntil(tokens, NOT_WHITESPACE)
var token = tokens.shift();
if (token === "changed") {
triggerSpec.changed = true;
} else if (token === "once") {
triggerSpec.once = true;
} else if (token === "consume") {
triggerSpec.consume = true;
} else if (token === "delay" && tokens[0] === ":") {
tokens.shift();
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
if (COMBINED_SELECTOR_START.test(tokens[0])) {
var from_arg = consumeCSSSelector(tokens);
} else {
var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA);
if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") {
tokens.shift();
var selector = consumeCSSSelector(tokens);
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
}
}
}
triggerSpec.from = from_arg;
} else if (token === "target" && tokens[0] === ":") {
tokens.shift();
triggerSpec.target = consumeCSSSelector(tokens);
} else if (token === "throttle" && tokens[0] === ":") {
tokens.shift();
triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "queue" && tokens[0] === ":") {
tokens.shift();
triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else if (token === "root" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeCSSSelector(tokens);
} else if (token === "threshold" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else {
triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()});
}
}
triggerSpecs.push(triggerSpec);
}
}
if (tokens.length === initialLength) {
triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()});
}
consumeUntil(tokens, NOT_WHITESPACE);
} while (tokens[0] === "," && tokens.shift())
var cache = htmx.config.triggerSpecsCache
triggerSpecs = (cache && cache[explicitTrigger]) || parseAndCacheTrigger(elt, explicitTrigger, cache)
}
if (triggerSpecs.length > 0) {
@ -1508,14 +1540,14 @@ return (function () {
return;
}
if (triggerSpec.throttle) {
if (triggerSpec.throttle > 0) {
if (!elementData.throttle) {
handler(elt, evt);
elementData.throttle = setTimeout(function () {
elementData.throttle = null;
}, triggerSpec.throttle);
}
} else if (triggerSpec.delay) {
} else if (triggerSpec.delay > 0) {
elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay);
} else {
triggerEvent(elt, 'htmx:trigger')
@ -1792,7 +1824,7 @@ return (function () {
handler(elt);
}
}
if (delay) {
if (delay > 0) {
setTimeout(load, delay);
} else {
load();
@ -1851,7 +1883,7 @@ return (function () {
if (!maybeFilterEvent(triggerSpec, elt, makeEvent("load", {elt: elt}))) {
loadImmediately(elt, handler, nodeData, triggerSpec.delay);
}
} else if (triggerSpec.pollInterval) {
} else if (triggerSpec.pollInterval > 0) {
nodeData.polling = true;
processPolling(elt, handler, triggerSpec);
} else {
@ -1894,26 +1926,35 @@ return (function () {
});
}
function hasChanceOfBeingBoosted() {
return document.querySelector("[hx-boost], [data-hx-boost]");
function shouldProcessHxOn(elt) {
var attributes = elt.attributes
for (var j = 0; j < attributes.length; j++) {
var attrName = attributes[j].name
if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:") ||
startsWith(attrName, "hx-on-") || startsWith(attrName, "data-hx-on-")) {
return true
}
}
return false
}
function findHxOnWildcardElements(elt) {
var node = null
var elements = []
if (shouldProcessHxOn(elt)) {
elements.push(elt)
}
if (document.evaluate) {
var iter = document.evaluate('//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") ]]', elt)
var iter = document.evaluate('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or' +
' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]', elt)
while (node = iter.iterateNext()) elements.push(node)
} else {
var allElements = document.getElementsByTagName("*")
var allElements = elt.getElementsByTagName("*")
for (var i = 0; i < allElements.length; i++) {
var attributes = allElements[i].attributes
for (var j = 0; j < attributes.length; j++) {
var attrName = attributes[j].name
if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:")) {
elements.push(allElements[i])
}
if (shouldProcessHxOn(allElements[i])) {
elements.push(allElements[i])
}
}
}
@ -1923,8 +1964,8 @@ return (function () {
function findElementsToProcess(elt) {
if (elt.querySelectorAll) {
var boostedElts = hasChanceOfBeingBoosted() ? ", a" : "";
var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," +
var boostedSelector = ", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";
var results = elt.querySelectorAll(VERB_SELECTOR + boostedSelector + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," +
" [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]");
return results;
} else {
@ -1970,7 +2011,7 @@ return (function () {
function countCurlies(line) {
var tokens = tokenizeString(line);
var netCurlies = 0;
for (let i = 0; i < tokens.length; i++) {
for (var i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token === "{") {
netCurlies++;
@ -2032,12 +2073,22 @@ return (function () {
for (var i = 0; i < elt.attributes.length; i++) {
var name = elt.attributes[i].name
var value = elt.attributes[i].value
if (startsWith(name, "hx-on:") || startsWith(name, "data-hx-on:")) {
let eventName = name.slice(name.indexOf(":") + 1)
// if the eventName starts with a colon, prepend "htmx" for shorthand support
if (startsWith(eventName, ":")) eventName = "htmx" + eventName
if (startsWith(name, "hx-on") || startsWith(name, "data-hx-on")) {
var afterOnPosition = name.indexOf("-on") + 3;
var nextChar = name.slice(afterOnPosition, afterOnPosition + 1);
if (nextChar === "-" || nextChar === ":") {
var eventName = name.slice(afterOnPosition + 1);
// if the eventName starts with a colon or dash, prepend "htmx" for shorthand support
if (startsWith(eventName, ":")) {
eventName = "htmx" + eventName
} else if (startsWith(eventName, "-")) {
eventName = "htmx:" + eventName.slice(1);
} else if (startsWith(eventName, "htmx-")) {
eventName = "htmx:" + eventName.slice(5);
}
addHxOnEventHandler(elt, eventName, value)
addHxOnEventHandler(elt, eventName, value)
}
}
}
}
@ -2315,7 +2366,9 @@ return (function () {
var details = {path: path, xhr:request};
triggerEvent(getDocument().body, "htmx:historyCacheMiss", details);
request.open('GET', path, true);
request.setRequestHeader("HX-Request", "true");
request.setRequestHeader("HX-History-Restore-Request", "true");
request.setRequestHeader("HX-Current-URL", getDocument().location.href);
request.onload = function () {
if (this.status >= 200 && this.status < 400) {
triggerEvent(getDocument().body, "htmx:historyCacheMissLoad", details);
@ -2430,7 +2483,7 @@ return (function () {
}
function shouldInclude(elt) {
if(elt.name === "" || elt.name == null || elt.disabled) {
if(elt.name === "" || elt.name == null || elt.disabled || closest(elt, "fieldset[disabled]")) {
return false;
}
// ignore "submitter" types (see jQuery src/serialize.js)
@ -2906,7 +2959,7 @@ return (function () {
}
function hasHeader(xhr, regexp) {
return xhr.getAllResponseHeaders().match(regexp);
return regexp.test(xhr.getAllResponseHeaders())
}
function ajaxHelper(verb, path, context) {
@ -3453,7 +3506,11 @@ return (function () {
}
if (hasHeader(xhr,/HX-Retarget:/i)) {
responseInfo.target = getDocument().querySelector(xhr.getResponseHeader("HX-Retarget"));
if (xhr.getResponseHeader("HX-Retarget") === "this") {
responseInfo.target = elt;
} else {
responseInfo.target = querySelectorExt(elt, xhr.getResponseHeader("HX-Retarget"));
}
}
var historyUpdate = determineHistoryUpdates(elt, responseInfo);
@ -3752,34 +3809,25 @@ return (function () {
//====================================================================
// Initialization
//====================================================================
/**
* We want to initialize the page elements after DOMContentLoaded
* fires, but there isn't always a good way to tell whether
* it has already fired when we get here or not.
*/
function ready(functionToCall) {
// call the function exactly once no matter how many times this is called
var callReadyFunction = function() {
if (!functionToCall) return;
functionToCall();
functionToCall = null;
};
var isReady = false
getDocument().addEventListener('DOMContentLoaded', function() {
isReady = true
})
if (getDocument().readyState === "complete") {
// DOMContentLoaded definitely fired, we can initialize the page
callReadyFunction();
}
else {
/* DOMContentLoaded *maybe* already fired, wait for
* the next DOMContentLoaded or readystatechange event
*/
getDocument().addEventListener("DOMContentLoaded", function() {
callReadyFunction();
});
getDocument().addEventListener("readystatechange", function() {
if (getDocument().readyState !== "complete") return;
callReadyFunction();
});
/**
* Execute a function now if DOMContentLoaded has fired, otherwise listen for it.
*
* This function uses isReady because there is no realiable way to ask the browswer whether
* the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded
* firing and readystate=complete.
*/
function ready(fn) {
// Checking readyState here is a failsafe in case the htmx script tag entered the DOM by
// some means other than the initial page load.
if (isReady || getDocument().readyState === 'complete') {
fn();
} else {
getDocument().addEventListener('DOMContentLoaded', fn);
}
}
@ -3827,7 +3875,9 @@ return (function () {
internalData.xhr.abort();
}
});
var originalPopstate = window.onpopstate;
/** @type {(ev: PopStateEvent) => any} */
const originalPopstate = window.onpopstate ? window.onpopstate.bind(window) : null;
/** @type {(ev: PopStateEvent) => any} */
window.onpopstate = function (event) {
if (event.state && event.state.htmx) {
restoreHistory();

View File

@ -15,6 +15,14 @@ describe("hx-on:* attribute", function() {
delete window.foo;
});
it("can use dashes rather than colons", function () {
var btn = make("<button hx-on-click='window.foo = true'>Foo</button>");
btn.click();
window.foo.should.equal(true);
delete window.foo;
});
it("can modify a parameter via htmx:configRequest", function () {
this.server.respondWith("POST", "/test", function (xhr) {
var params = parseParams(xhr.requestBody);
@ -26,6 +34,17 @@ describe("hx-on:* attribute", function() {
btn.innerText.should.equal("bar");
});
it("can modify a parameter via htmx:configRequest with dashes", function () {
this.server.respondWith("POST", "/test", function (xhr) {
var params = parseParams(xhr.requestBody);
xhr.respond(200, {}, params.foo);
});
var btn = make("<button hx-on-htmx-config-request='event.detail.parameters.foo = \"bar\"' hx-post='/test'>Foo</button>");
btn.click();
this.server.respond();
btn.innerText.should.equal("bar");
});
it("expands :: shorthand into htmx:", function () {
this.server.respondWith("POST", "/test", function (xhr) {
var params = parseParams(xhr.requestBody);
@ -37,6 +56,17 @@ describe("hx-on:* attribute", function() {
btn.innerText.should.equal("bar");
});
it("expands -- shorthand into htmx:", function () {
this.server.respondWith("POST", "/test", function (xhr) {
var params = parseParams(xhr.requestBody);
xhr.respond(200, {}, params.foo);
});
var btn = make("<button hx-on--config-request='event.detail.parameters.foo = \"bar\"' hx-post='/test'>Foo</button>");
btn.click();
this.server.respond();
btn.innerText.should.equal("bar");
});
it("can cancel an event via preventDefault for htmx:config-request", function () {
this.server.respondWith("POST", "/test", function (xhr) {
xhr.respond(200, {}, "<button>Bar</button>");
@ -185,7 +215,7 @@ describe("hx-on:* attribute", function() {
// check there is just one handler against each event
htmx.trigger(div, "increment-foo");
htmx.trigger(div, "increment-bar");
htmx.trigger(div, "increment-bar");
window.foo.should.equal(1);
window.bar.should.equal(1);

View File

@ -4,43 +4,53 @@ describe("hx-sse attribute", function() {
var listeners = {};
var wasClosed = false;
var mockEventSource = {
removeEventListener: function(name) {
delete listeners[name];
removeEventListener: function(name, l) {
listeners[name] = listeners[name].filter(function(elt, idx, arr) {
if (arr[idx] === l) {
return false;
}
return true;
})
},
addEventListener: function (message, l) {
listeners[message] = l;
addEventListener: function(message, l) {
if (listeners == undefined) {
listeners[message] = [];
}
listeners[message].push(l)
},
sendEvent: function (eventName, data) {
var listener = listeners[eventName];
if (listener) {
var event = htmx._("makeEvent")(eventName);
event.data = data;
listener(event);
sendEvent: function(eventName, data) {
var listeners = listeners[eventName];
if (listeners) {
listeners.forEach(function(listener) {
var event = htmx._("makeEvent")(eventName);
event.data = data;
listener(event);
}
}
},
close: function () {
close: function() {
wasClosed = true;
},
wasClosed: function () {
wasClosed: function() {
return wasClosed;
}
};
return mockEventSource;
}
beforeEach(function () {
beforeEach(function() {
this.server = makeServer();
var eventSource = mockEventSource();
this.eventSource = eventSource;
clearWorkArea();
htmx.createEventSource = function(){ return eventSource };
htmx.createEventSource = function() { return eventSource };
});
afterEach(function () {
afterEach(function() {
this.server.restore();
clearWorkArea();
});
it('handles basic sse triggering', function () {
it('handles basic sse triggering', function() {
this.server.respondWith("GET", "/d1", "div1 updated");
this.server.respondWith("GET", "/d2", "div2 updated");
@ -61,7 +71,7 @@ describe("hx-sse attribute", function() {
byId("d2").innerHTML.should.equal("div2 updated");
})
it('does not trigger events that arent named', function () {
it('does not trigger events that arent named', function() {
this.server.respondWith("GET", "/d1", "div1 updated");
@ -82,7 +92,7 @@ describe("hx-sse attribute", function() {
byId("d1").innerHTML.should.equal("div1 updated");
})
it('does not trigger events not on descendents', function () {
it('does not trigger events not on descendents', function() {
this.server.respondWith("GET", "/d1", "div1 updated");
@ -102,7 +112,7 @@ describe("hx-sse attribute", function() {
byId("d1").innerHTML.should.equal("div1");
})
it('is closed after removal', function () {
it('is closed after removal', function() {
this.server.respondWith("GET", "/test", "Clicked!");
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-sse="connect:/foo">' +
'<div id="d1" hx-trigger="sse:e1" hx-get="/d1">div1</div>' +
@ -112,7 +122,7 @@ describe("hx-sse attribute", function() {
this.eventSource.wasClosed().should.equal(true)
})
it('is closed after removal with no close and activity', function () {
it('is closed after removal with no close and activity', function() {
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-sse="connect:/foo">' +
'<div id="d1" hx-trigger="sse:e1" hx-get="/d1">div1</div>' +
'</div>');
@ -121,7 +131,7 @@ describe("hx-sse attribute", function() {
this.eventSource.wasClosed().should.equal(true)
})
it('swaps content properly on SSE swap', function () {
it('swaps content properly on SSE swap', function() {
var div = make('<div hx-sse="connect:/event_stream">\n' +
' <div id="d1" hx-sse="swap:e1"></div>\n' +
' <div id="d2" hx-sse="swap:e2"></div>\n' +
@ -136,5 +146,15 @@ describe("hx-sse attribute", function() {
byId("d2").innerText.should.equal("Event 2")
})
it('swaps swapped in content', function() {
var div = make('<div hx-sse="connect:/event_stream">\n' +
'<div id="d1" hx-sse="swap:e1" hx-swap="outerHTML"></div>\n' +
'</div>\n'
)
this.eventSource.sendEvent("e1", '<div id="d2" hx-sse="swap:e2"></div>')
this.eventSource.sendEvent("e2", 'Event 2')
byId("d2").innerText.should.equal("Event 2")
})
});

View File

@ -197,27 +197,43 @@ describe("hx-swap attribute", function(){
swapSpec(make("<div hx-swap='innerHTML'/>")).swapDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML'/>")).settleDelay.should.equal(0) // set to 0 in tests
swapSpec(make("<div hx-swap='innerHTML swap:10'/>")).swapDelay.should.equal(10)
swapSpec(make("<div hx-swap='innerHTML swap:0'/>")).swapDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML swap:0ms'/>")).swapDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML settle:10'/>")).settleDelay.should.equal(10)
swapSpec(make("<div hx-swap='innerHTML settle:0'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML settle:0s'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML swap:10 settle:11'/>")).swapDelay.should.equal(10)
swapSpec(make("<div hx-swap='innerHTML swap:10 settle:11'/>")).settleDelay.should.equal(11)
swapSpec(make("<div hx-swap='innerHTML settle:11 swap:10'/>")).swapDelay.should.equal(10)
swapSpec(make("<div hx-swap='innerHTML settle:11 swap:10'/>")).settleDelay.should.equal(11)
swapSpec(make("<div hx-swap='innerHTML settle:0 swap:0'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML settle:0 swap:0'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML settle:0s swap:0ms'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML settle:0s swap:0ms'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='innerHTML nonsense settle:11 swap:10'/>")).settleDelay.should.equal(11)
swapSpec(make("<div hx-swap='innerHTML nonsense settle:11 swap:10 '/>")).settleDelay.should.equal(11)
swapSpec(make("<div hx-swap='swap:10'/>")).swapStyle.should.equal("innerHTML")
swapSpec(make("<div hx-swap='swap:10'/>")).swapDelay.should.equal(10)
swapSpec(make("<div hx-swap='swap:0'/>")).swapDelay.should.equal(0);
swapSpec(make("<div hx-swap='swap:0s'/>")).swapDelay.should.equal(0);
swapSpec(make("<div hx-swap='settle:10'/>")).swapStyle.should.equal("innerHTML")
swapSpec(make("<div hx-swap='settle:10'/>")).settleDelay.should.equal(10)
swapSpec(make("<div hx-swap='settle:0'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='settle:0s'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='swap:10 settle:11'/>")).swapStyle.should.equal("innerHTML")
swapSpec(make("<div hx-swap='swap:10 settle:11'/>")).swapDelay.should.equal(10)
swapSpec(make("<div hx-swap='swap:10 settle:11'/>")).settleDelay.should.equal(11)
swapSpec(make("<div hx-swap='swap:0s settle:0'/>")).swapDelay.should.equal(0)
swapSpec(make("<div hx-swap='swap:0s settle:0'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='settle:11 swap:10'/>")).swapStyle.should.equal("innerHTML")
swapSpec(make("<div hx-swap='settle:11 swap:10'/>")).swapDelay.should.equal(10)
swapSpec(make("<div hx-swap='settle:11 swap:10'/>")).settleDelay.should.equal(11)
swapSpec(make("<div hx-swap='settle:0s swap:10'/>")).swapDelay.should.equal(10)
swapSpec(make("<div hx-swap='settle:0s swap:10'/>")).settleDelay.should.equal(0)
swapSpec(make("<div hx-swap='customstyle settle:11 swap:10'/>")).swapStyle.should.equal("customstyle")
})
@ -234,6 +250,17 @@ describe("hx-swap attribute", function(){
}, 30);
});
it("works immediately with no swap delay", function (done) {
this.server.respondWith("GET", "/test", "Clicked!");
var div = make(
"<div hx-get='/test' hx-swap='innerHTML swap:0ms'></div>"
);
div.click();
this.server.respond();
div.innerText.should.equal("Clicked!");
done();
});
it('works with a settle delay', function(done) {
this.server.respondWith("GET", "/test", "<div id='d1' class='foo' hx-get='/test' hx-swap='outerHTML settle:10ms'></div>");
var div = make("<div id='d1' hx-get='/test' hx-swap='outerHTML settle:10ms'></div>");
@ -246,6 +273,24 @@ describe("hx-swap attribute", function(){
}, 30);
});
it("works with no settle delay", function (done) {
this.server.respondWith(
"GET",
"/test",
"<div id='d1' class='foo' hx-get='/test' hx-swap='outerHTML settle:0ms'></div>"
);
var div = make(
"<div id='d1' hx-get='/test' hx-swap='outerHTML settle:0ms'></div>"
);
div.click();
this.server.respond();
div.classList.contains("foo").should.equal(false);
setTimeout(function () {
byId("d1").classList.contains("foo").should.equal(true);
done();
}, 30);
});
it('swap outerHTML properly w/ data-* prefix', function()
{
this.server.respondWith("GET", "/test", '<a id="a1" data-hx-get="/test2">Click Me</a>');

View File

@ -211,14 +211,20 @@ describe("hx-trigger attribute", function(){
var specExamples = {
"": [{trigger: 'click'}],
"every 1s": [{trigger: 'every', pollInterval: 1000}],
"every 0s": [{trigger: 'every', pollInterval: 0}],
"every 0ms": [{trigger: 'every', pollInterval: 0}],
"click": [{trigger: 'click'}],
"customEvent": [{trigger: 'customEvent'}],
"event changed": [{trigger: 'event', changed: true}],
"event once": [{trigger: 'event', once: true}],
"event delay:1s": [{trigger: 'event', delay: 1000}],
"event throttle:1s": [{trigger: 'event', throttle: 1000}],
"event delay:1s, foo": [{trigger: 'event', delay: 1000}, {trigger: 'foo'}],
"event throttle:0s": [{trigger: 'event', throttle: 0}],
"event throttle:0ms": [{trigger: 'event', throttle: 0}],
"event throttle:1s, foo": [{trigger: 'event', throttle: 1000}, {trigger: 'foo'}],
"event delay:1s": [{trigger: 'event', delay: 1000}],
"event delay:1s, foo": [{trigger: 'event', delay: 1000}, {trigger: 'foo'}],
"event delay:0s, foo": [{trigger: 'event', delay: 0}, {trigger: 'foo'}],
"event delay:0ms, foo": [{trigger: 'event', delay: 0}, {trigger: 'foo'}],
"event changed once delay:1s": [{trigger: 'event', changed: true, once: true, delay: 1000}],
"event1,event2": [{trigger: 'event1'}, {trigger: 'event2'}],
"event1, event2": [{trigger: 'event1'}, {trigger: 'event2'}],
@ -678,6 +684,37 @@ describe("hx-trigger attribute", function(){
}, 50);
});
it("A throttle of 0 does not multiple requests from happening", function (done) {
var requests = 0;
var server = this.server;
server.respondWith("GET", "/test", function (xhr) {
requests++;
xhr.respond(200, {}, "Requests: " + requests);
});
server.respondWith("GET", "/bar", "bar");
var div = make(
"<div hx-trigger='click throttle:0ms' hx-get='/test'></div>"
);
div.click();
server.respond();
div.innerText.should.equal("Requests: 1");
div.click();
server.respond();
div.innerText.should.equal("Requests: 2");
div.click();
server.respond();
div.innerText.should.equal("Requests: 3");
div.click();
server.respond();
div.innerText.should.equal("Requests: 4");
done()
});
it('delay delays the request', function(done)
{
var requests = 0;
@ -714,6 +751,37 @@ describe("hx-trigger attribute", function(){
}, 50);
});
it("A 0 delay does not delay the request", function (done) {
var requests = 0;
this.server.respondWith("GET", "/test", function (xhr) {
requests++;
xhr.respond(200, {}, "Requests: " + requests);
});
this.server.respondWith("GET", "/bar", "bar");
var div = make(
"<div hx-trigger='click delay:0ms' hx-get='/test'></div>"
);
div.click();
this.server.respond();
div.innerText.should.equal("Requests: 1");
div.click();
this.server.respond();
div.innerText.should.equal("Requests: 2");
div.click();
this.server.respond();
div.innerText.should.equal("Requests: 3");
div.click();
this.server.respond();
div.innerText.should.equal("Requests: 4");
done();
});
it('requests are queued with last one winning by default', function()
{
var requests = 0;
@ -954,5 +1022,56 @@ describe("hx-trigger attribute", function(){
make('<div hx-trigger="intersect root:{form input}" hx-get="/test">Not Called</div>');
});
it("uses trigger specs cache if defined", function () {
var initialCacheConfig = htmx.config.triggerSpecsCache
htmx.config.triggerSpecsCache = {}
var specExamples = {
"every 1s": [{trigger: 'every', pollInterval: 1000}],
"click": [{trigger: 'click'}],
"customEvent": [{trigger: 'customEvent'}],
"event changed": [{trigger: 'event', changed: true}],
"event once": [{trigger: 'event', once: true}],
"event delay:1s": [{trigger: 'event', delay: 1000}],
"event throttle:1s": [{trigger: 'event', throttle: 1000}],
"event delay:1s, foo": [{trigger: 'event', delay: 1000}, {trigger: 'foo'}],
"event throttle:1s, foo": [{trigger: 'event', throttle: 1000}, {trigger: 'foo'}],
"event changed once delay:1s": [{trigger: 'event', changed: true, once: true, delay: 1000}],
"event1,event2": [{trigger: 'event1'}, {trigger: 'event2'}],
"event1, event2": [{trigger: 'event1'}, {trigger: 'event2'}],
"event1 once, event2 changed": [{trigger: 'event1', once: true}, {trigger: 'event2', changed: true}],
}
for (var specString in specExamples) {
var div = make("<div hx-trigger='" + specString + "'></div>");
var spec = htmx._('getTriggerSpecs')(div);
spec.should.deep.equal(specExamples[specString], "Found : " + JSON.stringify(spec) + ", expected : " + JSON.stringify(specExamples[specString]) + " for spec: " + specString);
}
Object.keys(htmx.config.triggerSpecsCache).length.should.greaterThan(0)
Object.keys(htmx.config.triggerSpecsCache).length.should.equal(Object.keys(specExamples).length)
htmx.config.triggerSpecsCache = initialCacheConfig
})
it("correctly reuses trigger specs from the cache if defined", function () {
var initialCacheConfig = htmx.config.triggerSpecsCache
htmx.config.triggerSpecsCache = {}
var triggerStr = "event changed once delay:1s"
var expectedSpec = [{trigger: 'event', changed: true, once: true, delay: 1000}]
var div = make("<div hx-trigger='event changed once delay:1s'></div>");
var spec = htmx._('getTriggerSpecs')(div);
spec.should.deep.equal(expectedSpec, "Found : " + JSON.stringify(spec) + ", expected : " + JSON.stringify(expectedSpec) + " for spec: " + triggerStr);
spec.push("This should be carried to further identical specs thanks to the cache")
var div2 = make("<div hx-trigger='event changed once delay:1s'></div>");
var spec2 = htmx._('getTriggerSpecs')(div2);
spec2.should.deep.equal(spec, "Found : " + JSON.stringify(spec) + ", expected : " + JSON.stringify(spec2) + " for cached spec: " + triggerStr);
Object.keys(htmx.config.triggerSpecsCache).length.should.equal(1)
htmx.config.triggerSpecsCache = initialCacheConfig
})
})

View File

@ -348,4 +348,31 @@ describe("Core htmx AJAX headers", function () {
this.server.respond();
div.innerHTML.should.equal('<div>Yay! Welcome</div>');
})
it('request to restore history should include the HX-Request header', function () {
this.server.respondWith('GET', '/test', function (xhr) {
xhr.requestHeaders['HX-Request'].should.be.equal('true');
xhr.respond(200, {}, '');
});
htmx._('loadHistoryFromServer')('/test');
this.server.respond();
});
it('request to restore history should include the HX-History-Restore-Request header', function () {
this.server.respondWith('GET', '/test', function (xhr) {
xhr.requestHeaders['HX-History-Restore-Request'].should.be.equal('true');
xhr.respond(200, {}, '');
});
htmx._('loadHistoryFromServer')('/test');
this.server.respond();
});
it('request to restore history should include the HX-Current-URL header', function () {
this.server.respondWith('GET', '/test', function (xhr) {
chai.assert(typeof xhr.requestHeaders['HX-Current-URL'] !== 'undefined', 'HX-Current-URL should not be undefined');
xhr.respond(200, {}, '');
});
htmx._('loadHistoryFromServer')('/test');
this.server.respond();
});
});

View File

@ -76,9 +76,14 @@ describe("Core htmx internals Tests", function() {
it("handles parseInterval correctly", function() {
chai.expect(htmx.parseInterval("1ms")).to.be.equal(1);
chai.expect(htmx.parseInterval("300ms")).to.be.equal(300);
chai.expect(htmx.parseInterval("1s")).to.be.equal(1000)
chai.expect(htmx.parseInterval("1.5s")).to.be.equal(1500)
chai.expect(htmx.parseInterval("2s")).to.be.equal(2000)
chai.expect(htmx.parseInterval("1s")).to.be.equal(1000);
chai.expect(htmx.parseInterval("1.5s")).to.be.equal(1500);
chai.expect(htmx.parseInterval("2s")).to.be.equal(2000);
chai.expect(htmx.parseInterval("0ms")).to.be.equal(0);
chai.expect(htmx.parseInterval("0s")).to.be.equal(0);
chai.expect(htmx.parseInterval("0m")).to.be.equal(0);
chai.expect(htmx.parseInterval("0")).to.be.equal(0);
chai.expect(htmx.parseInterval("5")).to.be.equal(5);
chai.expect(htmx.parseInterval(null)).to.be.undefined
chai.expect(htmx.parseInterval("")).to.be.undefined

View File

@ -239,5 +239,12 @@ describe("Core htmx Parameter Handling", function() {
}
});
it('Input within disabled fieldset is excluded', function () {
var input = make('<form><input name="foo" value="bar"/><fieldset disabled><input name="do" value="rey"/></fieldset></form>');
var vals = htmx._('getInputValues')(input, "get").values;
vals['foo'].should.equal('bar');
should.equal(vals["do"], undefined);
})
});

View File

@ -0,0 +1,51 @@
describe("path-params extension", function() {
beforeEach(function () {
this.server = makeServer();
clearWorkArea();
});
afterEach(function () {
this.server.restore();
clearWorkArea();
});
it('uses parameter to populate path variable', function () {
var request;
htmx.on("htmx:beforeRequest", function (evt) {
request = evt;
});
var div = make("<div hx-ext='path-params' hx-get='/items/{itemId}' hx-vals='{\"itemId\":42}'></div>")
div.click();
should.equal(request.detail.requestConfig.path, '/items/42');
});
it('parameter is removed when used', function () {
var request;
htmx.on("htmx:beforeRequest", function (evt) {
request = evt;
});
var div = make("<div hx-ext='path-params' hx-get='/items/{itemId}' hx-vals='{\"itemId\":42, \"other\":43}'></div>")
div.click();
should.equal(request.detail.requestConfig.parameters.other, 43);
should.equal(request.detail.requestConfig.parameters.itemId, undefined);
});
it('parameter value is encoded', function () {
var request;
htmx.on("htmx:beforeRequest", function (evt) {
request = evt;
});
var div = make("<div hx-ext='path-params' hx-get='/items/{itemId}' hx-vals='{\"itemId\":\"a/b\"}'></div>")
div.click();
should.equal(request.detail.requestConfig.path, '/items/a%2Fb');
});
it('missing variables are ignored', function () {
var request;
htmx.on("htmx:beforeRequest", function (evt) {
request = evt;
});
var div = make("<div hx-ext='path-params' hx-get='/items/{itemId}/{subitem}' hx-vals='{\"itemId\":42}'></div>")
div.click();
should.equal(request.detail.requestConfig.path, '/items/42/{subitem}');
});
});

232
www/static/test/ext/sse.js Normal file
View File

@ -0,0 +1,232 @@
const { assert } = require("chai");
describe("sse extension", function() {
function mockEventSource() {
var listeners = {};
var wasClosed = false;
var url;
var mockEventSource = {
removeEventListener: function(name, l) {
listeners[name] = listeners[name].filter(function(elt, idx, arr) {
if (arr[idx] === l) {
return false;
}
return true;
})
},
addEventListener: function(message, l) {
if (listeners == undefined) {
listeners[message] = [];
}
listeners[message].push(l)
},
sendEvent: function(eventName, data) {
var listeners = listeners[eventName];
if (listeners) {
listeners.forEach(function(listener) {
var event = htmx._("makeEvent")(eventName);
event.data = data;
listener(event);
}
}
},
close: function() {
wasClosed = true;
},
wasClosed: function() {
return wasClosed;
},
connect: function(url) {
this.url = url
}
};
return mockEventSource;
}
beforeEach(function() {
this.server = makeServer();
var eventSource = mockEventSource();
this.eventSource = eventSource;
clearWorkArea();
htmx.createEventSource = function(url) {
eventSource.connect(url);
return eventSource;
};
});
afterEach(function() {
this.server.restore();
clearWorkArea();
});
it('handles basic sse triggering', function() {
this.server.respondWith("GET", "/d1", "div1 updated");
this.server.respondWith("GET", "/d2", "div2 updated");
var div = make('<div hx-ext="sse" sse-connect="/foo">' +
'<div id="d1" hx-trigger="sse:e1" hx-get="/d1">div1</div>' +
'<div id="d2" hx-trigger="sse:e2" hx-get="/d2">div2</div>' +
'</div>');
this.eventSource.sendEvent("e1");
this.server.respond();
byId("d1").innerHTML.should.equal("div1 updated");
byId("d2").innerHTML.should.equal("div2");
this.eventSource.sendEvent("e2");
this.server.respond();
byId("d1").innerHTML.should.equal("div1 updated");
byId("d2").innerHTML.should.equal("div2 updated");
})
it('does not trigger events that arent named', function() {
this.server.respondWith("GET", "/d1", "div1 updated");
var div = make('<div hx-ext="sse" sse-connect="/foo">' +
'<div id="d1" hx-trigger="sse:e1" hx-get="/d1">div1</div>' +
'</div>');
this.eventSource.sendEvent("foo");
this.server.respond();
byId("d1").innerHTML.should.equal("div1");
this.eventSource.sendEvent("e2");
this.server.respond();
byId("d1").innerHTML.should.equal("div1");
this.eventSource.sendEvent("e1");
this.server.respond();
byId("d1").innerHTML.should.equal("div1 updated");
})
it('does not trigger events not on descendents', function() {
this.server.respondWith("GET", "/d1", "div1 updated");
var div = make('<div hx-ext="sse" sse-connect="/foo"></div>' +
'<div id="d1" hx-trigger="sse:e1" hx-get="/d1">div1</div>');
this.eventSource.sendEvent("foo");
this.server.respond();
byId("d1").innerHTML.should.equal("div1");
this.eventSource.sendEvent("e2");
this.server.respond();
byId("d1").innerHTML.should.equal("div1");
this.eventSource.sendEvent("e1");
this.server.respond();
byId("d1").innerHTML.should.equal("div1");
})
it('is closed after removal, hx-trigger', function() {
this.server.respondWith("GET", "/test", "Clicked!");
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-ext="sse" sse-connect="/foo">' +
'<div id="d1" hx-trigger="sse:e1" hx-get="/d1">div1</div>' +
'</div>');
div.click();
this.server.respond();
this.eventSource.wasClosed().should.equal(true)
})
it('is closed after removal, hx-swap', function() {
this.server.respondWith("GET", "/test", "Clicked!");
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-ext="sse" sse-connect="/foo">' +
'<div id="d1" hx-swap="e1" hx-get="/d1">div1</div>' +
'</div>');
div.click();
this.server.respond();
this.eventSource.wasClosed().should.equal(true)
})
it('is closed after removal with no close and activity, hx-trigger', function() {
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-ext="sse" sse-connect="/foo">' +
'<div id="d1" hx-trigger="sse:e1" hx-get="/d1">div1</div>' +
'</div>');
div.parentElement.removeChild(div);
this.eventSource.sendEvent("e1")
this.eventSource.wasClosed().should.equal(true)
})
// sse and hx-trigger handlers are distinct
it('is closed after removal with no close and activity, sse-swap', function() {
var div = make('<div hx-get="/test" hx-swap="outerHTML" hx-ext="sse" sse-connect="/foo">' +
'<div id="d1" sse-swap="e1" hx-get="/d1">div1</div>' +
'</div>');
div.parentElement.removeChild(div);
this.eventSource.sendEvent("e1")
this.eventSource.wasClosed().should.equal(true)
})
it('swaps content properly on SSE swap', function() {
var div = make('<div hx-ext="sse" sse-connect="/event_stream">\n' +
' <div id="d1" sse-swap="e1"></div>\n' +
' <div id="d2" sse-swap="e2"></div>\n' +
'</div>\n');
byId("d1").innerText.should.equal("")
byId("d2").innerText.should.equal("")
this.eventSource.sendEvent("e1", "Event 1")
byId("d1").innerText.should.equal("Event 1")
byId("d2").innerText.should.equal("")
this.eventSource.sendEvent("e2", "Event 2")
byId("d1").innerText.should.equal("Event 1")
byId("d2").innerText.should.equal("Event 2")
})
it('swaps swapped in content', function() {
var div = make('<div hx-ext="sse" sse-connect="/event_stream">\n' +
'<div id="d1" sse-swap="e1" hx-swap="outerHTML"></div>\n' +
'</div>\n'
)
this.eventSource.sendEvent("e1", '<div id="d2" sse-swap="e2"></div>')
this.eventSource.sendEvent("e2", 'Event 2')
byId("d2").innerText.should.equal("Event 2")
})
it('works in a child of an hx-ext="sse" element', function() {
var div = make('<div hx-ext="sse">\n' +
'<div id="d1" sse-connect="/event_stream" sse-swap="e1">div1</div>\n' +
'</div>\n'
)
this.eventSource.url = "/event_stream"
})
it('only adds sseEventSource to elements with sse-connect', function() {
var div = make('<div hx-ext="sse" sse-connect="/event_stream" >\n' +
'<div id="d1" sse-swap="e1"></div>\n' +
'</div>');
(byId('d1')["htmx-internal-data"].sseEventSource == undefined).should.be.true
// Even when content is swapped in
this.eventSource.sendEvent("e1", '<div id="d2" sse-swap="e2"></div>');
(byId('d2')["htmx-internal-data"].sseEventSource == undefined).should.be.true
})
it('initializes connections in swapped content', function() {
this.server.respondWith("GET", "/d1", '<div><div sse-connect="/foo"><div id="d2" hx-trigger="sse:e2" hx-get="/d2">div2</div></div></div>');
this.server.respondWith("GET", "/d2", "div2 updated");
var div = make('<div hx-ext="sse" hx-get="/d1"></div>');
div.click();
this.server.respond();
this.eventSource.sendEvent("e2");
this.server.respond();
byId("d2").innerHTML.should.equal("div2 updated");
})
it('creates an eventsource on elements with sse-connect', function() {
var div = make('<div hx-ext="sse"><div id="d1"sse-connect="/event_stream"></div></div>');
(byId("d1")['htmx-internal-data'].sseEventSource == undefined).should.be.false;
})
});

View File

@ -158,9 +158,15 @@
<script src="../src/ext/ws.js"></script>
<script src="ext/ws.js"></script>
<script src="../src/ext/sse.js"></script>
<script src="ext/sse.js"></script>
<script src="../src/ext/response-targets.js"></script>
<script src="ext/response-targets.js"></script>
<script src="../src/ext/path-params.js"></script>
<script src="ext/path-params.js"></script>
<!-- events last so they don't screw up other tests -->
<script src="core/events.js"></script>
@ -174,7 +180,7 @@
</script>
<em>Work Area</em>
<hr/>
<div id="work-area" hx-history-elt hx-ext="sse">
<div id="work-area" hx-history-elt>
</div>
</body>
</html>

View File

@ -0,0 +1,8 @@
<head>
<title>Index content</title>
</head>
<h1># Index</h1>
<ul>
<li><code>&lt;title&gt;</code> <b>should not</b> spawn inside <code>&lt;main&gt;</code></li>
<li><code>&lt;title&gt;</code> <b>should be</b> <em>index content</em></li>
</ul>

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" hx-preserve="true">
<meta name="htmx-config" content='{"useTemplateFragments": true}' hx-preserve="true">
<title>Index content</title>
<style hx-preserve="true">
* {
font-family: Arial, sans-serif;
font-size: 24px;
}
code {
font-family: monospace;
font-size: 20px;
}
hr { border: 1px solid black; }
</style>
<script src="../../../src/htmx.js" hx-preserve="true"></script>
<script src="../../../src/ext/head-support.js" hx-preserve="true"></script>
</head>
<body hx-ext="head-support" hx-boost="true">
<header hx-push-url="false" hx-target="main" hx-swap="innerHTML">
<nav>
<ul>
<li><a href="./index-partial.html">Index</a></li>
<li><a href="./other-content.html">See other content</a></li>
</ul>
</nav><hr>
</header>
<main>
<h1># Index</h1>
<ul>
<li><code>&lt;title&gt;</code> <b>should not</b> spawn inside <code>&lt;main&gt;</code></li>
<li><code>&lt;title&gt;</code> <b>should be</b> <em>index content</em></li>
</ul>
</main>
</body>
</html>

View File

@ -0,0 +1,16 @@
<head>
<title>Other content</title>
<style>
body {
background: lightgreen;
}
</style>
</head>
<h1># Swapped content</h1>
<ul>
<li><code>&lt;title&gt;</code> and &lt;style&gt;
<b>should not</b> spawn inside <code>&lt;main&gt;</code></li>
<li><code>&lt;title&gt;</code> <b>should be</b> <em>other content</em></li>
<li>Background <b>should be</b> green</li>
</ul>

View File

@ -41,6 +41,7 @@
<ul>
<li><a href="hxboost_relative_resources">Relative Resources</a></li>
<li><a href="hxboost_template_parsing">Template Parsing</a></li>
<li><a href="hxboost_partial_template_parsing">Partial Template Parsing</a></li>
</ul>
</li>
</ul>

View File

@ -76,7 +76,8 @@ return (function () {
methodsThatUseUrlParams: ["get"],
selfRequestsOnly: false,
ignoreTitle: false,
scrollIntoViewOnBoost: true
scrollIntoViewOnBoost: true,
triggerSpecsCache: null,
},
parseInterval:parseInterval,
_:internalEval,
@ -88,7 +89,7 @@ return (function () {
sock.binaryType = htmx.config.wsBinaryType;
return sock;
},
version: "1.9.9"
version: "1.9.10"
};
/** @type {import("./htmx").HtmxInternalApi} */
@ -127,24 +128,40 @@ return (function () {
return "[hx-" + verb + "], [data-hx-" + verb + "]"
}).join(", ");
var HEAD_TAG_REGEX = makeTagRegEx('head'),
TITLE_TAG_REGEX = makeTagRegEx('title'),
SVG_TAGS_REGEX = makeTagRegEx('svg', true);
//====================================================================
// Utilities
//====================================================================
/**
* @param {string} tag
* @param {boolean} global
* @returns {RegExp}
*/
function makeTagRegEx(tag, global = false) {
return new RegExp(`<${tag}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${tag}>`,
global ? 'gim' : 'im');
}
function parseInterval(str) {
if (str == undefined) {
return undefined
return undefined;
}
let interval = NaN;
if (str.slice(-2) == "ms") {
return parseFloat(str.slice(0,-2)) || undefined
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);
}
if (str.slice(-1) == "s") {
return (parseFloat(str.slice(0,-1)) * 1000) || undefined
}
if (str.slice(-1) == "m") {
return (parseFloat(str.slice(0,-1)) * 1000 * 60) || undefined
}
return parseFloat(str) || undefined
return isNaN(interval) ? undefined : interval;
}
/**
@ -276,43 +293,46 @@ return (function () {
}
function aFullPageResponse(resp) {
return resp.match(/<body/);
return /<body/.test(resp)
}
/**
*
* @param {string} resp
* @param {string} response
* @returns {Element}
*/
function makeFragment(resp) {
var partialResponse = !aFullPageResponse(resp);
function makeFragment(response) {
var partialResponse = !aFullPageResponse(response);
var startTag = getStartTag(response);
var content = response;
if (startTag === 'head') {
content = content.replace(HEAD_TAG_REGEX, '');
}
if (htmx.config.useTemplateFragments && partialResponse) {
var documentFragment = parseHTML("<body><template>" + resp + "</template></body>", 0);
var documentFragment = parseHTML("<body><template>" + content + "</template></body>", 0);
// @ts-ignore type mismatch between DocumentFragment and Element.
// TODO: Are these close enough for htmx to use interchangeably?
return documentFragment.querySelector('template').content;
} else {
var startTag = getStartTag(resp);
switch (startTag) {
case "thead":
case "tbody":
case "tfoot":
case "colgroup":
case "caption":
return parseHTML("<table>" + resp + "</table>", 1);
case "col":
return parseHTML("<table><colgroup>" + resp + "</colgroup></table>", 2);
case "tr":
return parseHTML("<table><tbody>" + resp + "</tbody></table>", 2);
case "td":
case "th":
return parseHTML("<table><tbody><tr>" + resp + "</tr></tbody></table>", 3);
case "script":
case "style":
return parseHTML("<div>" + resp + "</div>", 1);
default:
return parseHTML(resp, 0);
}
}
switch (startTag) {
case "thead":
case "tbody":
case "tfoot":
case "colgroup":
case "caption":
return parseHTML("<table>" + content + "</table>", 1);
case "col":
return parseHTML("<table><colgroup>" + content + "</colgroup></table>", 2);
case "tr":
return parseHTML("<table><tbody>" + content + "</tbody></table>", 2);
case "td":
case "th":
return parseHTML("<table><tbody><tr>" + content + "</tr></tbody></table>", 3);
case "script":
case "style":
return parseHTML("<div>" + content + "</div>", 1);
default:
return parseHTML(content, 0);
}
}
@ -450,7 +470,7 @@ return (function () {
path = url.pathname + url.search;
}
// remove trailing slash, unless index page
if (!path.match('^/$')) {
if (!(/^\/$/.test(path))) {
path = path.replace(/\/+$/, '');
}
return path;
@ -827,7 +847,7 @@ return (function () {
var oobSelects = getClosestAttributeValue(elt, "hx-select-oob");
if (oobSelects) {
var oobSelectValues = oobSelects.split(",");
for (let i = 0; i < oobSelectValues.length; i++) {
for (var i = 0; i < oobSelectValues.length; i++) {
var oobSelectValue = oobSelectValues[i].split(":", 2);
var id = oobSelectValue[0].trim();
if (id.indexOf("#") === 0) {
@ -934,7 +954,7 @@ return (function () {
function deInitOnHandlers(elt) {
var internalData = getInternalData(elt);
if (internalData.onHandlers) {
for (let i = 0; i < internalData.onHandlers.length; i++) {
for (var i = 0; i < internalData.onHandlers.length; i++) {
const handlerInfo = internalData.onHandlers[i];
elt.removeEventListener(handlerInfo.event, handlerInfo.listener);
}
@ -960,10 +980,8 @@ return (function () {
}
});
}
if (internalData.initHash) {
internalData.initHash = null
}
deInitOnHandlers(element);
forEach(Object.keys(internalData), function(key) { delete internalData[key] });
}
function cleanUpElement(element) {
@ -987,7 +1005,6 @@ return (function () {
} else {
newElt = eltBeforeNewContent.nextSibling;
}
getInternalData(target).replacedWith = newElt; // tuck away so we can fire events on it later
settleInfo.elts = settleInfo.elts.filter(function(e) { return e != target });
while(newElt && newElt !== target) {
if (newElt.nodeType === Node.ELEMENT_NODE) {
@ -1099,9 +1116,8 @@ return (function () {
function findTitle(content) {
if (content.indexOf('<title') > -1) {
var contentWithSvgsRemoved = content.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
var result = contentWithSvgsRemoved.match(/<title(\s[^>]*>|>)([\s\S]*?)<\/title>/im);
var contentWithSvgsRemoved = content.replace(SVG_TAGS_REGEX, '');
var result = contentWithSvgsRemoved.match(TITLE_TAG_REGEX);
if (result) {
return result[2];
}
@ -1230,7 +1246,7 @@ return (function () {
function consumeUntil(tokens, match) {
var result = "";
while (tokens.length > 0 && !tokens[0].match(match)) {
while (tokens.length > 0 && !match.test(tokens[0])) {
result += tokens.shift();
}
return result;
@ -1250,6 +1266,99 @@ return (function () {
var INPUT_SELECTOR = 'input, textarea, select';
/**
* @param {HTMLElement} elt
* @param {string} explicitTrigger
* @param {cache} cache for trigger specs
* @returns {import("./htmx").HtmxTriggerSpecification[]}
*/
function parseAndCacheTrigger(elt, explicitTrigger, cache) {
var triggerSpecs = [];
var tokens = tokenizeString(explicitTrigger);
do {
consumeUntil(tokens, NOT_WHITESPACE);
var initialLength = tokens.length;
var trigger = consumeUntil(tokens, /[,\[\s]/);
if (trigger !== "") {
if (trigger === "every") {
var every = {trigger: 'every'};
consumeUntil(tokens, NOT_WHITESPACE);
every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/));
consumeUntil(tokens, NOT_WHITESPACE);
var eventFilter = maybeGenerateConditional(elt, tokens, "event");
if (eventFilter) {
every.eventFilter = eventFilter;
}
triggerSpecs.push(every);
} else if (trigger.indexOf("sse:") === 0) {
triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)});
} else {
var triggerSpec = {trigger: trigger};
var eventFilter = maybeGenerateConditional(elt, tokens, "event");
if (eventFilter) {
triggerSpec.eventFilter = eventFilter;
}
while (tokens.length > 0 && tokens[0] !== ",") {
consumeUntil(tokens, NOT_WHITESPACE)
var token = tokens.shift();
if (token === "changed") {
triggerSpec.changed = true;
} else if (token === "once") {
triggerSpec.once = true;
} else if (token === "consume") {
triggerSpec.consume = true;
} else if (token === "delay" && tokens[0] === ":") {
tokens.shift();
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
if (COMBINED_SELECTOR_START.test(tokens[0])) {
var from_arg = consumeCSSSelector(tokens);
} else {
var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA);
if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") {
tokens.shift();
var selector = consumeCSSSelector(tokens);
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
}
}
}
triggerSpec.from = from_arg;
} else if (token === "target" && tokens[0] === ":") {
tokens.shift();
triggerSpec.target = consumeCSSSelector(tokens);
} else if (token === "throttle" && tokens[0] === ":") {
tokens.shift();
triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "queue" && tokens[0] === ":") {
tokens.shift();
triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else if (token === "root" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeCSSSelector(tokens);
} else if (token === "threshold" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else {
triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()});
}
}
triggerSpecs.push(triggerSpec);
}
}
if (tokens.length === initialLength) {
triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()});
}
consumeUntil(tokens, NOT_WHITESPACE);
} while (tokens[0] === "," && tokens.shift())
if (cache) {
cache[explicitTrigger] = triggerSpecs
}
return triggerSpecs
}
/**
* @param {HTMLElement} elt
* @returns {import("./htmx").HtmxTriggerSpecification[]}
@ -1258,85 +1367,8 @@ return (function () {
var explicitTrigger = getAttributeValue(elt, 'hx-trigger');
var triggerSpecs = [];
if (explicitTrigger) {
var tokens = tokenizeString(explicitTrigger);
do {
consumeUntil(tokens, NOT_WHITESPACE);
var initialLength = tokens.length;
var trigger = consumeUntil(tokens, /[,\[\s]/);
if (trigger !== "") {
if (trigger === "every") {
var every = {trigger: 'every'};
consumeUntil(tokens, NOT_WHITESPACE);
every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/));
consumeUntil(tokens, NOT_WHITESPACE);
var eventFilter = maybeGenerateConditional(elt, tokens, "event");
if (eventFilter) {
every.eventFilter = eventFilter;
}
triggerSpecs.push(every);
} else if (trigger.indexOf("sse:") === 0) {
triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)});
} else {
var triggerSpec = {trigger: trigger};
var eventFilter = maybeGenerateConditional(elt, tokens, "event");
if (eventFilter) {
triggerSpec.eventFilter = eventFilter;
}
while (tokens.length > 0 && tokens[0] !== ",") {
consumeUntil(tokens, NOT_WHITESPACE)
var token = tokens.shift();
if (token === "changed") {
triggerSpec.changed = true;
} else if (token === "once") {
triggerSpec.once = true;
} else if (token === "consume") {
triggerSpec.consume = true;
} else if (token === "delay" && tokens[0] === ":") {
tokens.shift();
triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "from" && tokens[0] === ":") {
tokens.shift();
if (COMBINED_SELECTOR_START.test(tokens[0])) {
var from_arg = consumeCSSSelector(tokens);
} else {
var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA);
if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") {
tokens.shift();
var selector = consumeCSSSelector(tokens);
// `next` and `previous` allow a selector-less syntax
if (selector.length > 0) {
from_arg += " " + selector;
}
}
}
triggerSpec.from = from_arg;
} else if (token === "target" && tokens[0] === ":") {
tokens.shift();
triggerSpec.target = consumeCSSSelector(tokens);
} else if (token === "throttle" && tokens[0] === ":") {
tokens.shift();
triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA));
} else if (token === "queue" && tokens[0] === ":") {
tokens.shift();
triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else if (token === "root" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeCSSSelector(tokens);
} else if (token === "threshold" && tokens[0] === ":") {
tokens.shift();
triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA);
} else {
triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()});
}
}
triggerSpecs.push(triggerSpec);
}
}
if (tokens.length === initialLength) {
triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()});
}
consumeUntil(tokens, NOT_WHITESPACE);
} while (tokens[0] === "," && tokens.shift())
var cache = htmx.config.triggerSpecsCache
triggerSpecs = (cache && cache[explicitTrigger]) || parseAndCacheTrigger(elt, explicitTrigger, cache)
}
if (triggerSpecs.length > 0) {
@ -1508,14 +1540,14 @@ return (function () {
return;
}
if (triggerSpec.throttle) {
if (triggerSpec.throttle > 0) {
if (!elementData.throttle) {
handler(elt, evt);
elementData.throttle = setTimeout(function () {
elementData.throttle = null;
}, triggerSpec.throttle);
}
} else if (triggerSpec.delay) {
} else if (triggerSpec.delay > 0) {
elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay);
} else {
triggerEvent(elt, 'htmx:trigger')
@ -1792,7 +1824,7 @@ return (function () {
handler(elt);
}
}
if (delay) {
if (delay > 0) {
setTimeout(load, delay);
} else {
load();
@ -1851,7 +1883,7 @@ return (function () {
if (!maybeFilterEvent(triggerSpec, elt, makeEvent("load", {elt: elt}))) {
loadImmediately(elt, handler, nodeData, triggerSpec.delay);
}
} else if (triggerSpec.pollInterval) {
} else if (triggerSpec.pollInterval > 0) {
nodeData.polling = true;
processPolling(elt, handler, triggerSpec);
} else {
@ -1894,26 +1926,35 @@ return (function () {
});
}
function hasChanceOfBeingBoosted() {
return document.querySelector("[hx-boost], [data-hx-boost]");
function shouldProcessHxOn(elt) {
var attributes = elt.attributes
for (var j = 0; j < attributes.length; j++) {
var attrName = attributes[j].name
if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:") ||
startsWith(attrName, "hx-on-") || startsWith(attrName, "data-hx-on-")) {
return true
}
}
return false
}
function findHxOnWildcardElements(elt) {
var node = null
var elements = []
if (shouldProcessHxOn(elt)) {
elements.push(elt)
}
if (document.evaluate) {
var iter = document.evaluate('//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") ]]', elt)
var iter = document.evaluate('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or' +
' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]', elt)
while (node = iter.iterateNext()) elements.push(node)
} else {
var allElements = document.getElementsByTagName("*")
var allElements = elt.getElementsByTagName("*")
for (var i = 0; i < allElements.length; i++) {
var attributes = allElements[i].attributes
for (var j = 0; j < attributes.length; j++) {
var attrName = attributes[j].name
if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:")) {
elements.push(allElements[i])
}
if (shouldProcessHxOn(allElements[i])) {
elements.push(allElements[i])
}
}
}
@ -1923,8 +1964,8 @@ return (function () {
function findElementsToProcess(elt) {
if (elt.querySelectorAll) {
var boostedElts = hasChanceOfBeingBoosted() ? ", a" : "";
var results = elt.querySelectorAll(VERB_SELECTOR + boostedElts + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," +
var boostedSelector = ", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";
var results = elt.querySelectorAll(VERB_SELECTOR + boostedSelector + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," +
" [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]");
return results;
} else {
@ -1970,7 +2011,7 @@ return (function () {
function countCurlies(line) {
var tokens = tokenizeString(line);
var netCurlies = 0;
for (let i = 0; i < tokens.length; i++) {
for (var i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token === "{") {
netCurlies++;
@ -2032,12 +2073,22 @@ return (function () {
for (var i = 0; i < elt.attributes.length; i++) {
var name = elt.attributes[i].name
var value = elt.attributes[i].value
if (startsWith(name, "hx-on:") || startsWith(name, "data-hx-on:")) {
let eventName = name.slice(name.indexOf(":") + 1)
// if the eventName starts with a colon, prepend "htmx" for shorthand support
if (startsWith(eventName, ":")) eventName = "htmx" + eventName
if (startsWith(name, "hx-on") || startsWith(name, "data-hx-on")) {
var afterOnPosition = name.indexOf("-on") + 3;
var nextChar = name.slice(afterOnPosition, afterOnPosition + 1);
if (nextChar === "-" || nextChar === ":") {
var eventName = name.slice(afterOnPosition + 1);
// if the eventName starts with a colon or dash, prepend "htmx" for shorthand support
if (startsWith(eventName, ":")) {
eventName = "htmx" + eventName
} else if (startsWith(eventName, "-")) {
eventName = "htmx:" + eventName.slice(1);
} else if (startsWith(eventName, "htmx-")) {
eventName = "htmx:" + eventName.slice(5);
}
addHxOnEventHandler(elt, eventName, value)
addHxOnEventHandler(elt, eventName, value)
}
}
}
}
@ -2315,7 +2366,9 @@ return (function () {
var details = {path: path, xhr:request};
triggerEvent(getDocument().body, "htmx:historyCacheMiss", details);
request.open('GET', path, true);
request.setRequestHeader("HX-Request", "true");
request.setRequestHeader("HX-History-Restore-Request", "true");
request.setRequestHeader("HX-Current-URL", getDocument().location.href);
request.onload = function () {
if (this.status >= 200 && this.status < 400) {
triggerEvent(getDocument().body, "htmx:historyCacheMissLoad", details);
@ -2430,7 +2483,7 @@ return (function () {
}
function shouldInclude(elt) {
if(elt.name === "" || elt.name == null || elt.disabled) {
if(elt.name === "" || elt.name == null || elt.disabled || closest(elt, "fieldset[disabled]")) {
return false;
}
// ignore "submitter" types (see jQuery src/serialize.js)
@ -2906,7 +2959,7 @@ return (function () {
}
function hasHeader(xhr, regexp) {
return xhr.getAllResponseHeaders().match(regexp);
return regexp.test(xhr.getAllResponseHeaders())
}
function ajaxHelper(verb, path, context) {
@ -3453,7 +3506,11 @@ return (function () {
}
if (hasHeader(xhr,/HX-Retarget:/i)) {
responseInfo.target = getDocument().querySelector(xhr.getResponseHeader("HX-Retarget"));
if (xhr.getResponseHeader("HX-Retarget") === "this") {
responseInfo.target = elt;
} else {
responseInfo.target = querySelectorExt(elt, xhr.getResponseHeader("HX-Retarget"));
}
}
var historyUpdate = determineHistoryUpdates(elt, responseInfo);
@ -3752,34 +3809,25 @@ return (function () {
//====================================================================
// Initialization
//====================================================================
/**
* We want to initialize the page elements after DOMContentLoaded
* fires, but there isn't always a good way to tell whether
* it has already fired when we get here or not.
*/
function ready(functionToCall) {
// call the function exactly once no matter how many times this is called
var callReadyFunction = function() {
if (!functionToCall) return;
functionToCall();
functionToCall = null;
};
var isReady = false
getDocument().addEventListener('DOMContentLoaded', function() {
isReady = true
})
if (getDocument().readyState === "complete") {
// DOMContentLoaded definitely fired, we can initialize the page
callReadyFunction();
}
else {
/* DOMContentLoaded *maybe* already fired, wait for
* the next DOMContentLoaded or readystatechange event
*/
getDocument().addEventListener("DOMContentLoaded", function() {
callReadyFunction();
});
getDocument().addEventListener("readystatechange", function() {
if (getDocument().readyState !== "complete") return;
callReadyFunction();
});
/**
* Execute a function now if DOMContentLoaded has fired, otherwise listen for it.
*
* This function uses isReady because there is no realiable way to ask the browswer whether
* the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded
* firing and readystate=complete.
*/
function ready(fn) {
// Checking readyState here is a failsafe in case the htmx script tag entered the DOM by
// some means other than the initial page load.
if (isReady || getDocument().readyState === 'complete') {
fn();
} else {
getDocument().addEventListener('DOMContentLoaded', fn);
}
}
@ -3827,7 +3875,9 @@ return (function () {
internalData.xhr.abort();
}
});
var originalPopstate = window.onpopstate;
/** @type {(ev: PopStateEvent) => any} */
const originalPopstate = window.onpopstate ? window.onpopstate.bind(window) : null;
/** @type {(ev: PopStateEvent) => any} */
window.onpopstate = function (event) {
if (event.state && event.state.htmx) {
restoreHistory();